diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5e6f3b07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +./tee/private.pem +contracts/node_modules +bin/ \ No newline at end of file diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 00000000..bd539215 --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,70 @@ +--- + name: 'build container images' + + on: + push: + branches: + - master + - main + tags: + - '*' + concurrency: + group: ci-image-${{ github.head_ref || github.ref }}-${{ github.repository }} + cancel-in-progress: true + jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + id: prep + run: | + DOCKER_IMAGE=masaengineering/tee-worker + # Use branch name as default + VERSION=${GITHUB_REF#refs/heads/} + BINARY_VERSION=$(git describe --always --tags --dirty) + SHORTREF=${GITHUB_SHA::8} + # If this is git tag, use the tag name as a docker tag + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + fi + TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}" + # If the VERSION looks like a version number, assume that + # this is the most recent version of the image and also + # tag it 'latest'. + if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + TAGS="$TAGS,${DOCKER_IMAGE}:latest" + fi + # Set output parameters. + echo ::set-output name=binary_version::${BINARY_VERSION} + echo ::set-output name=tags::${TAGS} + echo ::set-output name=docker_image::${DOCKER_IMAGE} + - name: Set up QEMU + uses: docker/setup-qemu-action@master + with: + platforms: all + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@master + + - name: Login to DockerHub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build + uses: docker/build-push-action@v6 + with: + builder: ${{ steps.buildx.outputs.name }} + build-args: | + VERSION=${{ steps.prep.outputs.binary_version }} + context: ./ + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.prep.outputs.tags }} \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..247db88c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Run Go Tests + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Run tests + run: | + make test + sudo mv coverage/coverage.txt coverage.txt + sudo chmod 777 coverage.txt + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..8c8f9f55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +*.log + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +.env +.idea/** +.vscode/** +*.log +.DS_Store + +private.key +masa-oracle + +tools/tools +tools/*.json +tools/*.bin +bin/ + +# smart contracts +node_modules + +# Hardhat files +/cache +/artifacts + +# TypeChain files +/typechain +/typechain-types + +# solidity-coverage files +/coverage +/coverage.json + +# masa-keys generated and used with Docker +.masa-keys +yarn.lock +/pkg/masacrypto/cert.pem +/pkg/masacrypto/key.pem +/pkg/masacrypto/testCert.pem +/pkg/masacrypto/testKey.pem +CACHE/ + +cmd/masa-node-cli/key.txt +cmd/masa-node-cli/elabkey.txt +cmd/masa-node-cli/output.mp3 +cmd/masa-node-cli/log.txt +output.mp3 +docs/api-reference.md +./masa-node +transcription.txt +snippets.txt +.env copy + +# Build result of goreleaser +dist/ +bp-todo.md + +# TEE +tee/private.pem diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..19fd962a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +ARG egover=1.5.4 +ARG baseimage=ghcr.io/edgelesssys/ego-deploy:v${egover} +ARG VERSION +# Build the Go binary in a separate stage utilizing Makefile +FROM ghcr.io/edgelesssys/ego-dev:v${egover} AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +ENV VERSION=${VERSION} + +RUN make build + +RUN --mount=type=secret,id=private_key,dst=/app/tee/private.pem make sign + +RUN make bundle + +# Use the official Ubuntu 22.04 image as a base for the final image +FROM ${baseimage} AS base + +COPY --from=builder /app/bin/masa-tee-worker /usr/bin/masa-tee-worker + +# Create the 'masa' user and set up the home directory +RUN useradd -m -s /bin/bash masa && mkdir -p /home/masa && chown -R masa:masa /home/masa + +# Switch to user 'masa' for following commands +USER masa + +WORKDIR /home/masa + +# Expose necessary ports +EXPOSE 8080 + +# Set default command to start the Go application +CMD ego run /usr/bin/masa-tee-worker \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ee0a2b0f --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +VERSION?=$(shell git describe --tags --abbrev=0) +PWD:=$(shell pwd) +IMAGE?=masa-tee-worker:latest + +print-version: + @echo "Version: ${VERSION}" + +clean: + @rm -rf bin + +docker-compose-up: + @docker compose up --build + +build: + @ego-go build -v -gcflags=all="-N -l" -ldflags '-linkmode=external -extldflags=-static' -ldflags "-X github.com/masa-finance/tee-worker/internal/versioning.ApplicationVersion=${VERSION}" -o ./bin/masa-tee-worker ./cmd/tee-worker + +sign: tee/private.pem + @ego sign ./tee/masa-tee-worker.json + +bundle: + @ego bundle ./bin/masa-tee-worker + +run-simulate: docker-build + @docker run --net host -e OE_SIMULATION=1 --rm -v $(PWD)/.masa:/home/masa -ti $(IMAGE) + +run-sgx: docker-build + @docker run --device /dev/sgx_enclave --device /dev/sgx_provision --net host --rm -v $(PWD)/.masa:/home/masa -ti $(IMAGE) + +## TEE bits +tee/private.pem: + @openssl genrsa -out tee/private.pem -3 3072 + +docker-build: tee/private.pem + @docker build --secret id=private_key,src=./tee/private.pem -t $(IMAGE) -f Dockerfile . + +test: tee/private.pem + @docker build --build-arg baseimage=builder --secret id=private_key,src=./tee/private.pem -t $(IMAGE) -f Dockerfile . + @docker run --user root -v $(PWD)/coverage:/app/coverage --rm --workdir /app $(IMAGE) go test -coverprofile=coverage/coverage.txt -covermode=atomic -v ./... diff --git a/api/types/encrypted.go b/api/types/encrypted.go new file mode 100644 index 00000000..488699c1 --- /dev/null +++ b/api/types/encrypted.go @@ -0,0 +1,9 @@ +package types + +type EncryptedRequest struct { + EncryptedResult string `json:"encrypted_result"` +} + +type JobError struct { + Error string `json:"error"` +} diff --git a/api/types/job.go b/api/types/job.go new file mode 100644 index 00000000..463e053c --- /dev/null +++ b/api/types/job.go @@ -0,0 +1,32 @@ +package types + +import "encoding/json" + +type JobResponse struct { + UID string `json:"uid"` +} + +type JobArguments map[string]interface{} + +type JobResult struct { + Error string `json:"error"` + Data interface{} `json:"data"` +} + +func (jr JobResult) Success() bool { + return jr.Error == "" +} + +type Job struct { + Type string `json:"type"` + Arguments JobArguments `json:"arguments"` + UUID string `json:"-"` +} + +func (ja JobArguments) Unmarshal(i interface{}) error { + dat, err := json.Marshal(ja) + if err != nil { + return err + } + return json.Unmarshal(dat, i) +} diff --git a/cmd/tee-worker/main.go b/cmd/tee-worker/main.go new file mode 100644 index 00000000..bb91cb1f --- /dev/null +++ b/cmd/tee-worker/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "context" + + "github.com/masa-finance/tee-worker/internal/api" +) + +func main() { + api.Start(context.Background(), ":8080") +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..4e7c7bb5 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/masa-finance/tee-worker + +go 1.22.2 + +require ( + github.com/edgelesssys/ego v1.5.4 + github.com/google/uuid v1.6.0 + github.com/labstack/echo/v4 v4.12.0 + github.com/onsi/ginkgo/v2 v2.20.2 + github.com/onsi/gomega v1.34.2 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..7af2d091 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/edgelesssys/ego v1.5.4 h1:ADc6t5j77mOfwwu+akZX/I41YzHoseYiBcM5aME+Hb0= +github.com/edgelesssys/ego v1.5.4/go.mod h1:t10m29KSwG2hKwWFIq7/vuzfoKhPIdevOXx8nm636iU= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA= +github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= +github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/api_suite_test.go b/internal/api/api_suite_test.go new file mode 100644 index 00000000..9554956e --- /dev/null +++ b/internal/api/api_suite_test.go @@ -0,0 +1,13 @@ +package api_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "API integration test suite") +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 00000000..660b066a --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,86 @@ +package api_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/masa-finance/tee-worker/api/types" + . "github.com/masa-finance/tee-worker/internal/api" + "github.com/masa-finance/tee-worker/pkg/client" +) + +var _ = Describe("API", func() { + + var ( + clientInstance *client.Client + ctx context.Context + cancel context.CancelFunc + ) + + BeforeEach(func() { + // Start the server + ctx, cancel = context.WithCancel(context.Background()) + + go Start(ctx, "127.0.0.1:40912") + + // Wait for the server to start + time.Sleep(2 * time.Second) + + // Initialize the client + clientInstance = client.NewClient("http://localhost:40912") + }) + + AfterEach(func() { + // Stop the server + cancel() + }) + + It("should submit a job and get the correct result", func() { + // Step 1: Create the job request + job := types.Job{ + Type: "web-scraper", + Arguments: map[string]interface{}{ + "url": "google", + }, + } + + // Step 2: Submit the job + jobID, err := clientInstance.SubmitJob(job) + Expect(err).NotTo(HaveOccurred()) + Expect(jobID).NotTo(BeEmpty()) + + // Step 3: Wait for the job result + encryptedResult, err := clientInstance.WaitForResult(jobID, 10, time.Second) + Expect(err).NotTo(HaveOccurred()) + Expect(encryptedResult).NotTo(BeEmpty()) + + // Step 4: Decrypt the result + decryptedResult, err := clientInstance.DecryptResult(encryptedResult) + Expect(err).NotTo(HaveOccurred()) + Expect(decryptedResult).NotTo(BeEmpty()) + Expect(decryptedResult).To(ContainSubstring("google")) + }) + + It("bubble up errors", func() { + // Step 1: Create the job request + job := types.Job{ + Type: "not-existing scraper", + Arguments: map[string]interface{}{ + "url": "google", + }, + } + + // Step 2: Submit the job + jobID, err := clientInstance.SubmitJob(job) + Expect(err).NotTo(HaveOccurred()) + Expect(jobID).NotTo(BeEmpty()) + + // Step 3: Wait for the job result (should fail) + encryptedResult, err := clientInstance.WaitForResult(jobID, 10, time.Second) + Expect(err).To(HaveOccurred()) + Expect(encryptedResult).To(BeEmpty()) + }) +}) diff --git a/internal/api/start.go b/internal/api/start.go new file mode 100644 index 00000000..4ba69604 --- /dev/null +++ b/internal/api/start.go @@ -0,0 +1,141 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/masa-finance/tee-worker/api/types" + "github.com/masa-finance/tee-worker/internal/jobserver" + "github.com/masa-finance/tee-worker/pkg/tee" +) + +func Start(ctx context.Context, listenAddress string) { + // Echo instance + e := echo.New() + + // Jobserver instance + jobServer := jobserver.NewJobServer(2) + + go jobServer.Run(ctx) + + // Middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Routes + /* + - /job - POST - to send a job request + A job request has a type (string) and a map[string]interface{} as parameter. + - /job/{job_id} - GET - to get the status of a job + - /job/{job_id}/result - GET - to get the result of a job (encrypted) + - /job/{job_id}/status - GET - to get the status of a job (not encrypted) + - /decrypt - POST - to decrypt a message + Decripts a message. Takes two parameters: the encrypted result and the encrypted request (both strings) + + */ + + /* + curl localhost:8080/job -H "Content-Type: application/json" -d '{ + "type": "webscraper", "arguments": { "url": "google" } + }' + + */ + + e.POST("/job", func(c echo.Context) error { + job := types.Job{} + if err := c.Bind(&job); err != nil { + return err + } + + uuid := jobServer.AddJob(job) + + return c.JSON(http.StatusOK, types.JobResponse{UID: uuid}) + }) + + /* + curl localhost:8080/job/b678ff77-118d-4a7a-a6ea-190eb850c28a + */ + + e.GET("/job/:job_id", func(c echo.Context) error { + res, exists := jobServer.GetJobResult(c.Param("job_id")) + if !exists { + return c.JSON(http.StatusNotFound, types.JobError{Error: "Job not found"}) + } + + if res.Error != "" { + return c.JSON(http.StatusInternalServerError, types.JobError{Error: res.Error}) + } + + dat, err := json.Marshal(res.Data) + if err != nil { + return err + } + sealedData, err := tee.Seal(dat) + if err != nil { + return err + } + + b64 := base64.StdEncoding.EncodeToString(sealedData) + + return c.String(http.StatusOK, b64) + }) + + e.GET("/job/:job_id/status", func(c echo.Context) error { + res, exists := jobServer.GetJobResult(c.Param("job_id")) + if !exists { + return c.JSON(http.StatusNotFound, types.JobError{Error: "Job not found"}) + } + + dat, err := json.Marshal(res.Data) + if err != nil { + return err + } + sealedData, err := tee.Seal(dat) + if err != nil { + return err + } + + b64 := base64.StdEncoding.EncodeToString(sealedData) + + return c.String(http.StatusOK, b64) + }) + + /* + curl localhost:8080/decrypt -H "Content-Type: application/json" -d '{ "encrypted_result": "'$result'" }' + + */ + + e.POST("/decrypt", func(c echo.Context) error { + payload := types.EncryptedRequest{ + EncryptedResult: "", + } + + if err := c.Bind(&payload); err != nil { + return err + } + + b64, err := base64.StdEncoding.DecodeString(payload.EncryptedResult) + if err != nil { + return err + } + + dat, err := tee.Unseal(b64) + if err != nil { + return err + } + + return c.String(http.StatusOK, string(dat)) + }) + + go func() { + <-ctx.Done() + e.Close() + }() + + // Start server + e.Logger.Error(e.Start(listenAddress)) +} diff --git a/internal/jobs/jobs_suite_test.go b/internal/jobs/jobs_suite_test.go new file mode 100644 index 00000000..66450671 --- /dev/null +++ b/internal/jobs/jobs_suite_test.go @@ -0,0 +1,13 @@ +package jobs_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJobs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Worker's job types test suite") +} diff --git a/internal/jobs/webscraper.go b/internal/jobs/webscraper.go new file mode 100644 index 00000000..e50c5dba --- /dev/null +++ b/internal/jobs/webscraper.go @@ -0,0 +1,27 @@ +package jobs + +import ( + "github.com/masa-finance/tee-worker/api/types" +) + +type WebScraper struct { +} + +type WebScraperArgs struct { + URL string `json:"url"` +} + +func NewWebScraper() *WebScraper { + return &WebScraper{} +} + +func (ws *WebScraper) ExecuteJob(j types.Job) (types.JobResult, error) { + args := &WebScraperArgs{} + j.Arguments.Unmarshal(args) + + // Do the web scraping here + // For now, just return the URL + return types.JobResult{ + Data: args.URL, + }, nil +} diff --git a/internal/jobs/webscraper_test.go b/internal/jobs/webscraper_test.go new file mode 100644 index 00000000..70926d06 --- /dev/null +++ b/internal/jobs/webscraper_test.go @@ -0,0 +1,25 @@ +package jobs_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/masa-finance/tee-worker/api/types" + . "github.com/masa-finance/tee-worker/internal/jobs" +) + +var _ = Describe("Webscraper", func() { + It("should fake scraping for now", func() { + webScraper := NewWebScraper() + + res, err := webScraper.ExecuteJob(types.Job{ + Type: "web-scraper", + Arguments: map[string]interface{}{ + "url": "google", + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Error).To(BeEmpty()) + Expect(res.Data.(string)).To(Equal("google")) + }) +}) diff --git a/internal/jobserver/jobserver.go b/internal/jobserver/jobserver.go new file mode 100644 index 00000000..afd00a10 --- /dev/null +++ b/internal/jobserver/jobserver.go @@ -0,0 +1,56 @@ +package jobserver + +import ( + "context" + "sync" + + "github.com/google/uuid" + "github.com/masa-finance/tee-worker/api/types" +) + +type JobServer struct { + sync.Mutex + + jobChan chan types.Job + workers int + + results map[string]types.JobResult +} + +func NewJobServer(workers int) *JobServer { + if workers == 0 { + workers++ + } + + return &JobServer{ + jobChan: make(chan types.Job), + results: make(map[string]types.JobResult), + workers: workers, + } +} + +func (js *JobServer) Run(ctx context.Context) { + for i := 0; i < js.workers; i++ { + go js.worker(ctx) + } + + <-ctx.Done() +} + +func (js *JobServer) AddJob(j types.Job) string { + j.UUID = uuid.New().String() + defer func() { + go func() { + js.jobChan <- j + }() + }() + return j.UUID +} + +func (js *JobServer) GetJobResult(uuid string) (types.JobResult, bool) { + js.Lock() + defer js.Unlock() + + result, ok := js.results[uuid] + return result, ok +} diff --git a/internal/jobserver/jobserver_suite_test.go b/internal/jobserver/jobserver_suite_test.go new file mode 100644 index 00000000..350f1645 --- /dev/null +++ b/internal/jobserver/jobserver_suite_test.go @@ -0,0 +1,13 @@ +package jobserver_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJobServer(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "JobServer test suite") +} diff --git a/internal/jobserver/jobserver_test.go b/internal/jobserver/jobserver_test.go new file mode 100644 index 00000000..99e44031 --- /dev/null +++ b/internal/jobserver/jobserver_test.go @@ -0,0 +1,39 @@ +package jobserver_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/masa-finance/tee-worker/api/types" + . "github.com/masa-finance/tee-worker/internal/jobserver" +) + +var _ = Describe("Jobserver", func() { + It("runs jobs", func() { + jobserver := NewJobServer(2) + + uuid := jobserver.AddJob(types.Job{ + Type: "web-scraper", + Arguments: map[string]interface{}{ + "url": "google", + }, + }) + + Expect(uuid).ToNot(BeEmpty()) + + _, exists := jobserver.GetJobResult(uuid) + Expect(exists).ToNot(BeTrue()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go jobserver.Run(ctx) + + Eventually(func() bool { + result, exists := jobserver.GetJobResult(uuid) + return exists && result.Error == "" && result.Data.(string) == "google" + }, "5s").Should(Not(BeNil())) + }) +}) diff --git a/internal/jobserver/worker.go b/internal/jobserver/worker.go new file mode 100644 index 00000000..62ebd952 --- /dev/null +++ b/internal/jobserver/worker.go @@ -0,0 +1,53 @@ +package jobserver + +import ( + "context" + "fmt" + + "github.com/masa-finance/tee-worker/api/types" + "github.com/masa-finance/tee-worker/internal/jobs" +) + +func (js *JobServer) worker(c context.Context) { + for { + select { + case <-c.Done(): + fmt.Println("Context done") + return + + case j := <-js.jobChan: + fmt.Println("Job received: ", j) + js.doWork(j) + } + } +} + +type worker interface { + ExecuteJob(j types.Job) (types.JobResult, error) +} + +func (js *JobServer) doWork(j types.Job) error { + var w worker + switch j.Type { + case "web-scraper": + w = &jobs.WebScraper{} + default: + js.Lock() + js.results[j.UUID] = types.JobResult{ + Error: fmt.Sprintf("unknown job type: %s", j.Type), + } + js.Unlock() + return fmt.Errorf("unknown job type: %s", j.Type) + } + + result, err := w.ExecuteJob(j) + if err != nil { + result.Error = err.Error() + } + + js.Lock() + js.results[j.UUID] = result + js.Unlock() + + return nil +} diff --git a/internal/versioning/version.go b/internal/versioning/version.go new file mode 100644 index 00000000..1c2c58cf --- /dev/null +++ b/internal/versioning/version.go @@ -0,0 +1,9 @@ +package versioning + +var ( + ApplicationVersion string + + // XXX: Bump this value only when there are protocol changes that makes the oracle + // incompatible between version! + TEEWorkerVersion = `pre-alpha` +) diff --git a/pkg/client/http.go b/pkg/client/http.go new file mode 100644 index 00000000..58c2d7f0 --- /dev/null +++ b/pkg/client/http.go @@ -0,0 +1,132 @@ +package client + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/masa-finance/tee-worker/api/types" +) + +// Client represents a client to interact with the job server. +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// NewClient creates a new Client instance. +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{}, + } +} + +// SubmitJob submits a new job to the server and returns the job UID. +func (c *Client) SubmitJob(job types.Job) (string, error) { + jobJSON, err := json.Marshal(job) + if err != nil { + return "", fmt.Errorf("error marshaling job: %w", err) + } + + resp, err := c.HTTPClient.Post(c.BaseURL+"/job", "application/json", bytes.NewBuffer(jobJSON)) + if err != nil { + return "", fmt.Errorf("error sending POST request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("error: received status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %w", err) + } + + var jobResp types.JobResponse + err = json.Unmarshal(body, &jobResp) + if err != nil { + return "", fmt.Errorf("error unmarshaling response: %w", err) + } + + return jobResp.UID, nil +} + +// GetJobResult retrieves the encrypted result of a job. +func (c *Client) GetJobResult(jobID string) (string, error) { + resp, err := c.HTTPClient.Get(c.BaseURL + "/job/" + jobID) + if err != nil { + return "", fmt.Errorf("error sending GET request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode == http.StatusNotFound { + err = fmt.Errorf("job not found or not ready") + } + + if resp.StatusCode != http.StatusOK { + respErr := types.JobError{} + json.Unmarshal(body, &respErr) + err = fmt.Errorf("error: %s", respErr.Error) + } + + return string(body), err +} + +// DecryptResult sends the encrypted result to the server to decrypt it. +func (c *Client) DecryptResult(encryptedResult string) (string, error) { + decryptReq := types.EncryptedRequest{ + EncryptedResult: encryptedResult, + } + + decryptReqJSON, err := json.Marshal(decryptReq) + if err != nil { + return "", fmt.Errorf("error marshaling decrypt request: %w", err) + } + + resp, err := c.HTTPClient.Post(c.BaseURL+"/decrypt", "application/json", bytes.NewBuffer(decryptReqJSON)) + if err != nil { + return "", fmt.Errorf("error sending POST request to /decrypt: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("error: received status code %d from /decrypt", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body from /decrypt: %w", err) + } + + return string(body), nil +} + +// WaitForResult polls the server until the job result is ready or a timeout occurs. +func (c *Client) WaitForResult(jobID string, maxRetries int, delay time.Duration) (result string, err error) { + retries := 0 + + for { + if retries >= maxRetries { + return "", errors.New("max retries reached") + } + retries++ + + result, err = c.GetJobResult(jobID) + if err == nil { + break + } + } + + return +} diff --git a/pkg/client/http_suite_test.go b/pkg/client/http_suite_test.go new file mode 100644 index 00000000..83266027 --- /dev/null +++ b/pkg/client/http_suite_test.go @@ -0,0 +1,13 @@ +package client_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestClient(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Client test suite") +} diff --git a/pkg/client/http_test.go b/pkg/client/http_test.go new file mode 100644 index 00000000..02762d11 --- /dev/null +++ b/pkg/client/http_test.go @@ -0,0 +1,140 @@ +package client_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + "github.com/masa-finance/tee-worker/pkg/client" + . "github.com/masa-finance/tee-worker/pkg/client" + + "github.com/masa-finance/tee-worker/api/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Client", func() { + var ( + c *Client + server *httptest.Server + mockJobUID string + ) + + // Define a helper function to create a new test server + setupServer := func(statusCode int, responseBody interface{}) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(statusCode) + if responseBody != nil { + json.NewEncoder(w).Encode(responseBody) + } + })) + } + + BeforeEach(func() { + mockJobUID = "mock-job-uid" + }) + + Context("SubmitJob", func() { + var mockJob types.Job + + BeforeEach(func() { + mockJob = types.Job{UUID: "job1"} + server = setupServer(http.StatusOK, types.JobResponse{UID: mockJobUID}) + c = client.NewClient(server.URL) + }) + + AfterEach(func() { + server.Close() + }) + + It("should submit a job successfully", func() { + uid, err := c.SubmitJob(mockJob) + Expect(err).NotTo(HaveOccurred()) + Expect(uid).To(Equal(mockJobUID)) + }) + + It("should return an error on HTTP failure", func() { + server.Close() // close the server to simulate network error + _, err := c.SubmitJob(mockJob) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("GetJobResult", func() { + BeforeEach(func() { + server = setupServer(http.StatusOK, "encrypted-result") + c = client.NewClient(server.URL) + }) + + AfterEach(func() { + server.Close() + }) + + It("should retrieve the job result successfully", func() { + result, err := c.GetJobResult("job1") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("\"encrypted-result\"\n")) + }) + + It("should return an error when job is not found", func() { + server.Close() + server = setupServer(http.StatusNotFound, types.JobError{Error: "job not found"}) + c = client.NewClient(server.URL) + _, err := c.GetJobResult("invalid-job-id") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("job not found")) + }) + }) + + Context("DecryptResult", func() { + BeforeEach(func() { + server = setupServer(http.StatusOK, "decrypted-result") + c = client.NewClient(server.URL) + }) + + AfterEach(func() { + server.Close() + }) + + It("should decrypt result successfully", func() { + result, err := c.DecryptResult("encrypted-result") + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("\"decrypted-result\"\n")) + }) + + It("should return an error on decryption failure", func() { + server.Close() + server = setupServer(http.StatusInternalServerError, nil) + c = client.NewClient(server.URL) + _, err := c.DecryptResult("encrypted-result") + Expect(err).To(HaveOccurred()) + }) + }) + + Context("WaitForResult", func() { + BeforeEach(func() { + server = setupServer(http.StatusOK, "encrypted-result") + c = client.NewClient(server.URL) + }) + + AfterEach(func() { + server.Close() + }) + + It("should wait and retrieve result successfully", func() { + result, err := c.WaitForResult("job1", 3, time.Millisecond*10) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal("\"encrypted-result\"\n")) + }) + + It("should fail after max retries", func() { + server.Close() // simulate unavailability + result, err := c.WaitForResult("job1", 3, time.Millisecond*10) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("max retries reached")) + Expect(result).To(BeEmpty()) + }) + }) +}) diff --git a/pkg/tee/report.go b/pkg/tee/report.go new file mode 100644 index 00000000..dd6ca79d --- /dev/null +++ b/pkg/tee/report.go @@ -0,0 +1,49 @@ +package tee + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + + "github.com/edgelesssys/ego/attestation" + "github.com/edgelesssys/ego/attestation/tcbstatus" + "github.com/edgelesssys/ego/enclave" +) + +func VerifyReport(reportBytes, certBytes, signer []byte, production bool) error { + report, err := enclave.VerifyRemoteReport(reportBytes) + if err == attestation.ErrTCBLevelInvalid { + fmt.Printf("Warning: TCB level is invalid: %v\n%v\n", report.TCBStatus, tcbstatus.Explain(report.TCBStatus)) + // XXX: We'll ignore this issue for now. For an app that should run in production, you must decide which of the different TCBStatus values are acceptable for you to continue.") + if production { + return errors.New("TCB level is invalid") + } + } else if err != nil { + return err + } + + hash := sha256.Sum256(certBytes) + if !bytes.Equal(report.Data[:len(hash)], hash[:]) { + return errors.New("report data does not match the certificate's hash") + } + + // You can either verify the UniqueID or the tuple (SignerID, ProductID, SecurityVersion, Debug). + if report.SecurityVersion < 2 { + return errors.New("invalid security version") + } + if binary.LittleEndian.Uint16(report.ProductID) != 1234 { + return errors.New("invalid product") + } + if !bytes.Equal(report.SignerID, signer) { + return errors.New("invalid signer") + } + + // For production, you must also verify that report.Debug == false + if production && report.Debug { + return errors.New("debug is true") + } + + return nil +} diff --git a/pkg/tee/sealer.go b/pkg/tee/sealer.go new file mode 100644 index 00000000..7237b469 --- /dev/null +++ b/pkg/tee/sealer.go @@ -0,0 +1,23 @@ +package tee + +/* + +This is a wrapper package just to ease out adding logics that +should apply to all callers of the sealer. + +XXX: Currently it is equivalent as calling the library directly, +and provides just syntax sugar. + +*/ + +import "github.com/edgelesssys/ego/ecrypto" + +// Seal uses the TEE Product Key to encrypt the plaintext +// The Product key is the one bound to the signer pubkey +func Seal(plaintext []byte) ([]byte, error) { + return ecrypto.SealWithProductKey(plaintext, nil) +} + +func Unseal(encryptedText []byte) ([]byte, error) { + return ecrypto.Unseal(encryptedText, nil) +} diff --git a/tee/embed.go b/tee/embed.go new file mode 100644 index 00000000..6bda1d6f --- /dev/null +++ b/tee/embed.go @@ -0,0 +1,6 @@ +package teekeys + +import "embed" + +//go:embed * +var EmbeddedCertificates embed.FS diff --git a/tee/masa-tee-worker.json b/tee/masa-tee-worker.json new file mode 100644 index 00000000..066b520c --- /dev/null +++ b/tee/masa-tee-worker.json @@ -0,0 +1,27 @@ +{ + "exe": "./../bin/masa-tee-worker", + "key": "private.pem", + "debug": true, + "heapSize": 4092, + "executableHeap": false, + "productID": 1, + "securityVersion": 1, + "mounts": [ + { + "target": "/tmp", + "type": "memfs" + } + ], + "env": [ + { + "name": "LANG", + "fromHost": true + } + ], + "files": [ + { + "source": "/etc/ssl/certs/ca-certificates.crt", + "target": "/etc/ssl/certs/ca-certificates.crt" + } + ] +} \ No newline at end of file diff --git a/tee/public.pem b/tee/public.pem new file mode 100644 index 00000000..94325c0a --- /dev/null +++ b/tee/public.pem @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBoDANBgkqhkiG9w0BAQEFAAOCAY0AMIIBiAKCAYEA4bIGCf6JzsB3Bn2gLcid +nqtiTb08Y2r/7JZYn5QXPWZYneu2bKGYsd+BuAC7ILH0Z7ldMrzqwpWADbNg5Uwf +IA3ao5V9aeikt3SdIoaCtGJiCFqFkqe5a8SlS1momiID3tSMMpgC6B9DGHJRbadJ +zLl3cL8X7AKX3BaDK8StUMEbqOI8/lhBpSyuJrqA9+kplUD9CL5GkSvTXwAK0yks +/bTI47D80AvemmWtiVhsGvf/YPZUw5fylTTfyxjmh9mloCVfyp5IyAj13aaWnSyk +wbknzxsaiN8yb5hNbYtlyf1TAaOsKtlZunnM8gY3DKz0QXjHU5nv4DXPK8yO3NAx +rUIN68M+sMdE6MqIejSrgRJaQdEwWBbRzsfe9iU5YzNJm3YFwGNhqn8LMPOsUYt4 +ngkXjBENbwZDHbWOzvdPLR6oD/Z9yae0OCcWs9X2ic9bbM01kvBFlVR44u2CAkk1 +4i3shZGMBNRVPsKI9uacVd/C+H5nYBMKsSX0epZPfx/1AgED +-----END PUBLIC KEY-----