diff --git a/Makefile b/Makefile index 569549eb..5d655424 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ CONFIG_DIR ?= /etc/cocos SERVICE_NAME ?= cocos-manager SERVICE_DIR ?= /etc/systemd/system SERVICE_FILE = init/systemd/$(SERVICE_NAME).service +IGVM_BUILD_SCRIPT := ./scripts/igvmmeasure/igvm.sh define compile_service CGO_ENABLED=$(CGO_ENABLED) GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) \ @@ -27,8 +28,9 @@ endef all: $(SERVICES) -$(SERVICES): +$(SERVICES): $(call compile_service,$@) + @if [ "$@" = "cli" ]; then $(MAKE) build-igvm; fi $(ATTESTATION_POLICY): $(MAKE) -C ./scripts/attestation_policy @@ -61,3 +63,7 @@ stop: install_service: sudo install -m 644 $(SERVICE_FILE) $(SERVICE_DIR)/$(SERVICE_NAME).service sudo systemctl daemon-reload + +build-igvm: + @echo "Running build script for igvmmeasure..." + @$(IGVM_BUILD_SCRIPT) diff --git a/cli/README.md b/cli/README.md index 2856050c..b3a05a92 100644 --- a/cli/README.md +++ b/cli/README.md @@ -100,3 +100,22 @@ When defining the manifest dataset and algorithm checksums are required. This ca ```bash ./build/cocos-cli checksum ``` + +#### Measure IGVM file +We assume that our current working directory is the root of the cocos repository, both on the host machine and in the VM. + +`igvmmeasure` calculates the launch measurement for an IGVM file and can generate a signed version. It ensures integrity by precomputing the expected launch digest, which can be verified against the attestation report. The tool parses IGVM directives, outputs the measurement as a hex string, or creates a signed file for verification at guest launch. + +##### Example +We measure an IGVM file using our measure command, run: + +```bash +./build/cocos-cli igvmmeasure /path/to/igvm/file +``` + +The tool will parse the directives in the IGVM file, calculate the launch measurement, and output the computed digest. If successful, it prints the measurement to standard output. + +Here is a sample output +``` +91c4929bec2d0ecf11a708e09f0a57d7d82208bcba2451564444a4b01c22d047995ca27f9053f86de4e8063e9f810548 +``` \ No newline at end of file diff --git a/cli/attestation.go b/cli/attestation.go index 36232fbb..73ef5daa 100644 --- a/cli/attestation.go +++ b/cli/attestation.go @@ -624,6 +624,28 @@ func (cli *CLI) NewValidateAttestationValidationCmd() *cobra.Command { return cmd } +func (cli *CLI) NewMeasureCmd(igvmBinaryPath string) *cobra.Command { + igvmmeasureCmd := &cobra.Command{ + Use: "igvmmeasure ", + Short: "Measure an IGVM file", + Long: `igvmmeasure measures an IGVM file and outputs the calculated measurement. + It ensures integrity verification for the IGVM file.`, + + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("error: No input file provided") + } + + inputFile := args[0] + + return cli.measurement.Run(inputFile) + }, + } + + return igvmmeasureCmd +} + func sevsnpverify(cmd *cobra.Command, args []string) error { cmd.Println("Checking attestation") diff --git a/cli/attestation_test.go b/cli/attestation_test.go index 9678efa6..8aa21d67 100644 --- a/cli/attestation_test.go +++ b/cli/attestation_test.go @@ -34,8 +34,6 @@ func TestNewAttestationCmd(t *testing.T) { var buf bytes.Buffer cmd.SetOut(&buf) - cmd.SetOutput(&buf) - reportData := bytes.Repeat([]byte{0x01}, quoteprovider.Nonce) mockSDK.On("Attestation", mock.Anything, [quoteprovider.Nonce]byte(reportData), mock.Anything).Return(nil) @@ -159,7 +157,7 @@ func TestNewGetAttestationCmd(t *testing.T) { } cmd := cli.NewGetAttestationCmd() var buf bytes.Buffer - cmd.SetOutput(&buf) + cmd.SetOut(&buf) mockSDK.On("Attestation", mock.Anything, [quoteprovider.Nonce]byte(bytes.Repeat([]byte{0x00}, quoteprovider.Nonce)), [vtpm.Nonce]byte(bytes.Repeat([]byte{0x00}, vtpm.Nonce)), mock.Anything, mock.Anything).Return(tc.mockError).Run(func(args mock.Arguments) { _, err := args.Get(4).(*os.File).Write(tc.mockResponse) @@ -285,6 +283,61 @@ func TestNewValidateAttestationValidationCmd(t *testing.T) { }) } +type MockMeasurement struct { + mock.Mock +} + +func (m *MockMeasurement) Run(igvmBinaryPath string) error { + args := m.Called(igvmBinaryPath) + return args.Error(0) +} + +func (m *MockMeasurement) Stop() error { + args := m.Called() + return args.Error(0) +} + +func TestNewMeasureCmd_RunSuccess(t *testing.T) { + cliInstance := &CLI{} + mockMeasurement := new(MockMeasurement) + cliInstance.measurement = mockMeasurement + + mockMeasurement.On("Run", "testfile.igvm").Return(nil) + + cmd := cliInstance.NewMeasureCmd("fake_binary_path") + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"testfile.igvm"}) + + err := cmd.Execute() + + assert.NoError(t, err) + mockMeasurement.AssertExpectations(t) +} + +func TestNewMeasureCmd_RunError(t *testing.T) { + cliInstance := &CLI{} + mockMeasurement := new(MockMeasurement) + cliInstance.measurement = mockMeasurement + expectedError := errors.New("mocked measurement error") + + mockMeasurement.On("Run", "testfile.igvm").Return(expectedError) + + cmd := cliInstance.NewMeasureCmd("fake_binary_path") + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"testfile.igvm"}) + + err := cmd.Execute() + + assert.Error(t, err) + assert.Equal(t, expectedError.Error(), err.Error()) + mockMeasurement.AssertExpectations(t) +} + func TestParseConfig(t *testing.T) { cfgString = "" err := parseConfig() diff --git a/cli/sdk.go b/cli/sdk.go index 76eebf78..53b1d9b0 100644 --- a/cli/sdk.go +++ b/cli/sdk.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/ultravioletrs/cocos/manager" + "github.com/ultravioletrs/cocos/pkg/attestation/igvmmeasure" "github.com/ultravioletrs/cocos/pkg/clients/grpc" "github.com/ultravioletrs/cocos/pkg/clients/grpc/agent" managergrpc "github.com/ultravioletrs/cocos/pkg/clients/grpc/manager" @@ -22,12 +23,14 @@ type CLI struct { client grpc.Client managerClient manager.ManagerServiceClient connectErr error + measurement igvmmeasure.MeasurementProvider } -func New(agentConfig grpc.AgentClientConfig, managerConfig grpc.ManagerClientConfig) *CLI { +func New(agentConfig grpc.AgentClientConfig, managerConfig grpc.ManagerClientConfig, measurement igvmmeasure.MeasurementProvider) *CLI { return &CLI{ agentConfig: agentConfig, managerConfig: managerConfig, + measurement: measurement, } } diff --git a/cmd/cli/main.go b/cmd/cli/main.go index ace4d1a0..64bb2302 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/ultravioletrs/cocos/cli" + "github.com/ultravioletrs/cocos/pkg/attestation/igvmmeasure" "github.com/ultravioletrs/cocos/pkg/clients/grpc" cmd "github.com/virtee/sev-snp-measure-go/sevsnpmeasure/cmd" ) @@ -28,7 +29,8 @@ const ( ) type config struct { - LogLevel string `env:"AGENT_LOG_LEVEL" envDefault:"info"` + LogLevel string `env:"AGENT_LOG_LEVEL" envDefault:"info"` + IgvmBinaryPath string `env:"IGVM_BINARY_PATH" envDefault:"./build/igvmmeasure"` } func main() { @@ -106,7 +108,14 @@ func main() { return } - cliSVC := cli.New(agentGRPCConfig, managerGRPCConfig) + measurement, err := igvmmeasure.NewIgvmMeasurement(cfg.IgvmBinaryPath, os.Stderr, os.Stdout) + if err != nil { + message := color.New(color.FgRed).Sprintf("failed to initialize measurement: %s", err) // Use %s instead of %w + rootCmd.Println(message) + return + } + + cliSVC := cli.New(agentGRPCConfig, managerGRPCConfig, measurement) if err := cliSVC.InitializeAgentSDK(rootCmd); err == nil { defer cliSVC.Close() @@ -136,6 +145,7 @@ func main() { // measure. rootCmd.AddCommand(cmd.NewRootCmd()) + rootCmd.AddCommand(cliSVC.NewMeasureCmd(cfg.IgvmBinaryPath)) // Flags keysCmd.PersistentFlags().StringVarP( diff --git a/pkg/attestation/igvmmeasure/igvmmeasure.go b/pkg/attestation/igvmmeasure/igvmmeasure.go new file mode 100644 index 00000000..4e760ce5 --- /dev/null +++ b/pkg/attestation/igvmmeasure/igvmmeasure.go @@ -0,0 +1,79 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 +package igvmmeasure + +import ( + "fmt" + "io" + "os/exec" + "strings" +) + +type MeasurementProvider interface { + Run(igvmBinaryPath string) error + Stop() error +} +type IgvmMeasurement struct { + binPath string + options []string + stderr io.Writer + stdout io.Writer + cmd *exec.Cmd + execCommand func(name string, arg ...string) *exec.Cmd +} + +func NewIgvmMeasurement(binPath string, stderr, stdout io.Writer) (*IgvmMeasurement, error) { + if binPath == "" { + return nil, fmt.Errorf("pathToBinary cannot be empty") + } + + return &IgvmMeasurement{ + binPath: binPath, + stderr: stderr, + stdout: stdout, + execCommand: exec.Command, + }, nil +} + +func (m *IgvmMeasurement) Run(pathToFile string) error { + binary := m.binPath + args := []string{} + args = append(args, m.options...) + args = append(args, pathToFile) + args = append(args, "measure") + args = append(args, "-b") + + out, err := m.execCommand(binary, args...).CombinedOutput() + if err != nil { + fmt.Println("Error:", err) + } + outputString := string(out) + + lines := strings.Split(strings.TrimSpace(outputString), "\n") + + if len(lines) == 1 { + outputString = strings.ToLower(outputString) + fmt.Print(outputString) + } else { + return fmt.Errorf("error: %s", outputString) + } + + return nil +} + +func (m *IgvmMeasurement) Stop() error { + if m.cmd == nil || m.cmd.Process == nil { + return fmt.Errorf("no running process to stop") + } + + if err := m.cmd.Process.Kill(); err != nil { + return fmt.Errorf("failed to stop process: %v", err) + } + + return nil +} + +// SetExecCommand allows tests to inject a mock execCommand function. +func (m *IgvmMeasurement) SetExecCommand(cmdFunc func(name string, arg ...string) *exec.Cmd) { + m.execCommand = cmdFunc +} diff --git a/pkg/attestation/igvmmeasure/igvmmeasure_test.go b/pkg/attestation/igvmmeasure/igvmmeasure_test.go new file mode 100644 index 00000000..ec049a8d --- /dev/null +++ b/pkg/attestation/igvmmeasure/igvmmeasure_test.go @@ -0,0 +1,77 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 +package igvmmeasure + +import ( + "bytes" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIgvmMeasurement(t *testing.T) { + tests := []struct { + name string + setup func() *IgvmMeasurement + runArgs string + expectErr bool + expectedErr string + }{ + { + name: "NewIgvmMeasurement - Empty pathToBinary", + setup: func() *IgvmMeasurement { + igvm, err := NewIgvmMeasurement("", nil, nil) + assert.Error(t, err) + assert.Nil(t, igvm) + return nil + }, + expectErr: true, + expectedErr: "pathToBinary cannot be empty", + }, + { + name: "Run - Successful Execution", + setup: func() *IgvmMeasurement { + igvm, _ := NewIgvmMeasurement("/valid/path", nil, nil) + igvm.SetExecCommand(func(name string, arg ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo 'measurement successful'") + }) + return igvm + }, + expectErr: false, + }, + { + name: "Run - Failure Execution", + setup: func() *IgvmMeasurement { + igvm, _ := NewIgvmMeasurement("/invalid/path", nil, nil) + igvm.SetExecCommand(func(name string, arg ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo 'some error occurred\nextra line' && exit 1") + }) + return igvm + }, + expectErr: true, + expectedErr: "error: some error occurred\nextra line", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + igvm := tc.setup() + + if igvm != nil { + buf := new(bytes.Buffer) + igvm.stdout = buf + igvm.stderr = buf + + err := igvm.Run(tc.runArgs) + if tc.expectErr { + assert.Error(t, err) + assert.Equal(t, strings.TrimSpace(tc.expectedErr), strings.TrimSpace(err.Error())) + } else { + assert.NoError(t, err) + } + } + }) + } +} diff --git a/scripts/igvmmeasure/igvm.sh b/scripts/igvmmeasure/igvm.sh new file mode 100755 index 00000000..974cef1f --- /dev/null +++ b/scripts/igvmmeasure/igvm.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +REPO_URL="https://github.com/coconut-svsm/svsm.git" +BUILD_DIR="$(cd "$(dirname "$0")/../.." && pwd)/build" + + +mkdir -p "$BUILD_DIR" + +# Define the target directory for cloning inside the build directory +TARGET_DIR="$BUILD_DIR/svsm" +SUBDIR="igvmmeasure" + +# Clone the repository if it doesn't exist +if [ -d "$TARGET_DIR" ]; then + echo "Repository already exists in $TARGET_DIR. Pulling latest changes..." + cd "$TARGET_DIR" && git pull +else + echo "Cloning repository into $TARGET_DIR..." + git clone --recurse-submodules "$REPO_URL" "$TARGET_DIR" +fi + +# Ensure submodules are up to date +cd "$TARGET_DIR" +git submodule update --init --recursive + +# Check if the required subdirectory exists +if [ -d "$SUBDIR" ]; then + echo "Successfully cloned repository and found '$SUBDIR' directory." +else + echo "Error: '$SUBDIR' directory not found inside '$TARGET_DIR'." + exit 1 +fi + +echo "Building the Rust crate..." + +RELEASE=1 make bin/igvmmeasure BUILDDIR="$BUILD_DIR" + +mv bin/igvmmeasure "$BUILD_DIR/" + +echo "Binary stored in: $BUILD_DIR/igvmmeasure"