diff --git a/README.md b/README.md index dfc6228..6c5b612 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,25 @@ go install github.com/edgelesssys/uplosi@latest Alternatively, you can download the binary from the [releases page](https://github.com/edgelesssys/uplosi/releases/latest). -# Usage +# Uploading OS Images + +The main purpose of uplosi is to upload OS images to cloud providers. +Uploading images requires a [configuration file](#configuration) to be present in the current working directory. + +## Usage ```shell-session uplosi upload [flags] ``` -## Examples +### Examples ```shell-session # edit uplosi.conf, then run uplosi upload image.raw -i ``` -## Flags +### Flags - `--disable-variant-glob` string: list of variant name globs to disable - `--enable-variant-glob` string: list of variant name globs to enable @@ -32,6 +37,35 @@ uplosi upload image.raw -i - `-i`,`--increment-version`: increment version number after upload - `-v`: version for uplosi +# Calculating TPM PCR Values + +> [!WARNING] +> This command is highly experimental. It does not account for all PCRs and all possibilities of their measurements, +> is only tested in a very specific environment and should not be used in production use-cases. + +Uplosi can also, from a given raw disk image, calculate TPM PCR values (Namely PCRs 4, 9, and 11) +ahead of the image boot to allow to craft remote attestation policies for images. +It requires `systemd-dissect` to be present in `$PATH`. + +## Usage + +```shell-session +sudo uplosi measurements [flags] +``` + +### Examples + +```shell-session +sudo uplosi measurements image.raw --output-file pcrs.json +``` + +### Flags + +- `--output-file` string: path to a JSON file the output should be written to +- `--uki-path` string: path to the unified kernel image (UKI) within the ESP of the image (default: `/boot/EFI/BOOT/BOOTX64.EFI`) +- `-h`,`--help`: help for uplosi +- `-v`: version for uplosi + # Configuration Uplosi requires configuration files in [TOML format](https://toml.io/en/) to be present in the user's workspace (CWD). diff --git a/cmd.go b/cmd.go index 99414b3..08dc932 100644 --- a/cmd.go +++ b/cmd.go @@ -7,30 +7,9 @@ SPDX-License-Identifier: Apache-2.0 package main import ( - "context" - "errors" - "fmt" - "io" - "log" "os" - "path" - "path/filepath" - "strconv" - "strings" - "github.com/BurntSushi/toml" - "github.com/edgelesssys/uplosi/aws" - "github.com/edgelesssys/uplosi/azure" - "github.com/edgelesssys/uplosi/config" - "github.com/edgelesssys/uplosi/gcp" - "github.com/edgelesssys/uplosi/openstack" "github.com/spf13/cobra" - "golang.org/x/mod/semver" -) - -const ( - configName = "uplosi.conf" - configDir = "uplosi.conf.d" ) var version = "0.0.0-dev" @@ -44,281 +23,7 @@ func newRootCmd() *cobra.Command { cmd.SetOut(os.Stdout) cmd.InitDefaultVersionFlag() cmd.AddCommand(newUploadCmd()) + cmd.AddCommand(newMeasurementsCmd()) return cmd } - -func newUploadCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "upload ", - Short: "Upload an image to a cloud provider", - Args: cobra.ExactArgs(1), - RunE: runUpload, - } - cmd.Flags().BoolP("increment-version", "i", false, "increment version number after upload") - cmd.Flags().StringSlice("enable-variant-glob", []string{"*"}, "list of variant name globs to enable") - cmd.Flags().StringSlice("disable-variant-glob", nil, "list of variant name globs to disable") - cmd.Flags().StringP("config", "c", "", fmt.Sprintf("path to directory %s and %s resides in", configName, configDir)) - - return cmd -} - -func runUpload(cmd *cobra.Command, args []string) error { - logger := log.New(cmd.ErrOrStderr(), "", log.LstdFlags) - imagePath := args[0] - - flags, err := parseUploadFlags(cmd) - if err != nil { - return fmt.Errorf("parsing flags: %w", err) - } - - conf, err := parseConfigFiles(flags.configPath) - if err != nil { - return fmt.Errorf("parsing config files: %w", err) - } - - versionFiles := map[string][]byte{} - versionFileLookup := func(name string) ([]byte, error) { - if _, ok := versionFiles[name]; !ok { - ver, err := os.ReadFile(name) - if err != nil { - return nil, fmt.Errorf("reading version file: %w", err) - } - versionFiles[name] = ver - } - return versionFiles[name], nil - } - - allRefs := []string{} - err = conf.ForEach( - func(name string, cfg config.Config) error { - refs, err := uploadVariant(cmd.Context(), imagePath, name, cfg, logger) - if err != nil { - return err - } - allRefs = append(allRefs, refs...) - return nil - }, - versionFileLookup, - func(name string) bool { - return filterGlobAny(flags.enableVariantGlobs, name) - }, - func(name string) bool { - return !filterGlobAny(flags.disableVariantGlobs, name) - }, - ) - if err != nil { - return fmt.Errorf("uploading variants: %w", err) - } - - for _, ref := range allRefs { - fmt.Println(ref) - } - - if !flags.incrementVersion { - return nil - } - if len(versionFiles) == 0 { - return errors.New("increment-version flag set but no version files found") - } - for versionFileName, version := range versionFiles { - newVer, err := incrementSemver(strings.TrimSpace(string(version))) - if err != nil { - return fmt.Errorf("incrementing semver: %w", err) - } - if err := writeVersionFile(versionFileName, []byte(newVer)); err != nil { - return fmt.Errorf("writing version file: %w", err) - } - } - return nil -} - -func uploadVariant(ctx context.Context, imagePath, variant string, config config.Config, logger *log.Logger) ([]string, error) { - var prepper Prepper - var upload Uploader - var err error - - if len(variant) > 0 { - log.Println("Uploading variant", variant) - } - - switch strings.ToLower(config.Provider) { - case "aws": - prepper = &aws.Prepper{} - upload, err = aws.NewUploader(config, logger) - if err != nil { - return nil, fmt.Errorf("creating aws uploader: %w", err) - } - case "azure": - prepper = &azure.Prepper{} - upload, err = azure.NewUploader(config, logger) - if err != nil { - return nil, fmt.Errorf("creating azure uploader: %w", err) - } - case "gcp": - prepper = &gcp.Prepper{} - upload, err = gcp.NewUploader(config, logger) - if err != nil { - return nil, fmt.Errorf("creating gcp uploader: %w", err) - } - case "openstack": - prepper = &openstack.Prepper{} - upload, err = openstack.NewUploader(config, logger) - if err != nil { - return nil, fmt.Errorf("creating openstack uploader: %w", err) - } - default: - return nil, fmt.Errorf("unknown provider: %s", config.Provider) - } - - tmpDir, err := os.MkdirTemp("", "uplosi-") - if err != nil { - return nil, fmt.Errorf("creating temp dir: %w", err) - } - defer os.RemoveAll(tmpDir) - - imagePath, err = prepper.Prepare(ctx, imagePath, tmpDir) - if err != nil { - return nil, fmt.Errorf("preparing image: %w", err) - } - image, err := os.Open(imagePath) - if err != nil { - return nil, fmt.Errorf("opening image: %w", err) - } - defer image.Close() - imageFi, err := image.Stat() - if err != nil { - return nil, fmt.Errorf("getting image stats: %w", err) - } - - refs, err := upload.Upload(ctx, image, imageFi.Size()) - if err != nil { - return nil, fmt.Errorf("uploading image: %w", err) - } - - return refs, nil -} - -type uploadFlags struct { - incrementVersion bool - enableVariantGlobs []string - disableVariantGlobs []string - configPath string -} - -func parseUploadFlags(cmd *cobra.Command) (*uploadFlags, error) { - incrementVersion, err := cmd.Flags().GetBool("increment-version") - if err != nil { - return nil, fmt.Errorf("getting increment-version flag: %w", err) - } - enableVariantGlobs, err := cmd.Flags().GetStringSlice("enable-variant-glob") - if err != nil { - return nil, fmt.Errorf("getting enable-variant-glob flag: %w", err) - } - disableVariantGlobs, err := cmd.Flags().GetStringSlice("disable-variant-glob") - if err != nil { - return nil, fmt.Errorf("getting disable-variant-glob flag: %w", err) - } - configPath, err := cmd.Flags().GetString("config") - if err != nil { - return nil, fmt.Errorf("getting config flag: %w", err) - } - return &uploadFlags{ - incrementVersion: incrementVersion, - enableVariantGlobs: enableVariantGlobs, - disableVariantGlobs: disableVariantGlobs, - configPath: configPath, - }, nil -} - -func filterGlobAny(globs []string, name string) bool { - for _, glob := range globs { - if ok, _ := filepath.Match(glob, name); ok { - return true - } - } - return false -} - -func readTOMLFile(path string, data any) error { - configFile, err := os.OpenFile(path, os.O_RDONLY, os.ModeAppend) - if err != nil { - return fmt.Errorf("opening file: %w", err) - } - defer configFile.Close() - if _, err := toml.NewDecoder(configFile).Decode(data); err != nil { - return fmt.Errorf("decoding file: %w", err) - } - return nil -} - -func writeVersionFile(path string, data []byte) error { - versionFile, err := os.OpenFile(path, os.O_WRONLY, os.ModeAppend) - if err != nil { - return fmt.Errorf("opening file: %w", err) - } - defer versionFile.Close() - if _, err := versionFile.Write(data); err != nil { - return fmt.Errorf("writing file: %w", err) - } - return nil -} - -type Prepper interface { - Prepare(ctx context.Context, imagePath, tmpDir string) (string, error) -} - -type Uploader interface { - Upload(ctx context.Context, image io.ReadSeeker, size int64) (refs []string, retErr error) -} - -func parseConfigFiles(configPath string) (*config.ConfigFile, error) { - configLocation := path.Join(configPath, configName) - configDirLocation := path.Join(configPath, configDir) - - var conf config.ConfigFile - if err := readTOMLFile(configLocation, &conf); err != nil { - return nil, fmt.Errorf("reading config: %w", err) - } - - dirEntries, err := os.ReadDir(configDirLocation) - if os.IsNotExist(err) { - return &conf, nil - } - if err != nil { - return nil, fmt.Errorf("reading config dir: %w", err) - } - for _, dirEntry := range dirEntries { - var cfgOverlay config.ConfigFile - if dirEntry.IsDir() { - continue - } - if filepath.Ext(dirEntry.Name()) != ".conf" { - continue - } - if err := readTOMLFile(filepath.Join(configDir, dirEntry.Name()), &cfgOverlay); err != nil { - return nil, fmt.Errorf("reading config: %w", err) - } - if err := conf.Merge(cfgOverlay); err != nil { - return nil, fmt.Errorf("merging config: %w", err) - } - } - return &conf, nil -} - -func incrementSemver(version string) (string, error) { - canonical := strings.TrimPrefix(semver.Canonical("v"+version), "v") - parts := strings.Split(canonical, ".") - if len(parts) != 3 { - return "", fmt.Errorf("splitting canonical version: %s, %v", canonical, parts) - } - - patch := parts[2] - patchNum, err := strconv.Atoi(patch) - if err != nil { - return "", fmt.Errorf("converting patch number: %w", err) - } - - patchNum++ - return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], patchNum), nil -} diff --git a/flake.nix b/flake.nix index 272f58f..8c34f49 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ version = "0.2.0"; src = ./.; # this needs to be updated together with go.mod / go.sum - vendorHash = "sha256-1o30yZCx43FsFn+b6OQgRluJhBD9cSPXpXQv++IvxoI="; + vendorHash = "sha256-7Pe52of/RZZ8qzQS6roSduHAi6n61r61jkHkOVL6ylY="; CGO_ENABLED = 0; diff --git a/go.mod b/go.mod index 9c6d210..30a6f4d 100644 --- a/go.mod +++ b/go.mod @@ -15,18 +15,22 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.54.2 github.com/aws/aws-sdk-go-v2/service/sts v1.28.9 github.com/aws/smithy-go v1.20.2 + github.com/foxboron/go-uefi v0.0.0-20240522180132-205d5597883a github.com/googleapis/gax-go/v2 v2.12.4 github.com/gophercloud/gophercloud v1.11.0 github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 github.com/open-policy-agent/opa v0.64.1 + github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.9.0 + go.uber.org/goleak v1.3.0 golang.org/x/mod v0.17.0 ) require ( cloud.google.com/go/auth v0.4.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect ) require ( @@ -98,7 +102,7 @@ require ( golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/text v0.15.0 golang.org/x/time v0.5.0 // indirect google.golang.org/api v0.181.0 // indirect google.golang.org/genproto v0.0.0-20240515191416-fc5f0ca64291 // indirect diff --git a/go.sum b/go.sum index c088421..e1a991a 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,8 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -116,6 +116,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +github.com/foxboron/go-uefi v0.0.0-20240522180132-205d5597883a h1:Q/VIO3QAlaF95JqVVF39udInPR76lu02yrMDInavm8Q= +github.com/foxboron/go-uefi v0.0.0-20240522180132-205d5597883a/go.mod h1:ffg/fkDeOYicEQLoO2yFFGt00KUTYVXI+rfnc8il6vQ= github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= @@ -190,8 +192,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -226,6 +228,8 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -248,6 +252,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= @@ -268,6 +274,8 @@ go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2L go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -334,8 +342,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= -golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= diff --git a/measured-boot/extract/extract.go b/measured-boot/extract/extract.go new file mode 100644 index 0000000..ee137ef --- /dev/null +++ b/measured-boot/extract/extract.go @@ -0,0 +1,114 @@ +package extract + +import ( + "crypto/sha256" + "debug/pe" + "fmt" + "io" + "os/exec" + "sort" + + "github.com/edgelesssys/uplosi/measured-boot/pesection" +) + +// CopyFrom is a wrapper for systemd-dissect --copy-from. +func CopyFrom(dissectToolchain, image, path, output string) error { + if dissectToolchain == "" { + dissectToolchain = "systemd-dissect" + } + out, err := exec.Command(dissectToolchain, "--copy-from", image, path, output).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to extract %s from %s: %v\n%s", path, image, err, out) + } + return nil +} + +// PeSectionReader returns a reader for the named section of a PE file. +func PeSectionReader(peFile io.ReaderAt, section string) (io.Reader, error) { + f, err := pe.NewFile(peFile) + if err != nil { + return nil, err + } + defer f.Close() + + for _, s := range f.Sections { + if s.Name == section { + return io.LimitReader(s.Open(), int64(s.VirtualSize)), nil + } + } + + sectionNames := make([]string, len(f.Sections)) + for i, s := range f.Sections { + sectionNames[i] = s.Name + } + + return nil, fmt.Errorf("section %q not found in %v", section, sectionNames) +} + +// PeFileSectionDigests returns the section digests of a PE file. +func PeFileSectionDigests(peFile io.ReaderAt) ([]pesection.PESection, error) { + f, err := pe.NewFile(peFile) + if err != nil { + return nil, err + } + defer f.Close() + + sections := make([]pesection.PESection, len(f.Sections)) + for i, section := range f.Sections { + sectionDigest := sha256.New() + sectionReader := section.Open() + _, err := io.CopyN(sectionDigest, sectionReader, int64(section.VirtualSize)) + if err != nil { + return nil, err + } + + sections[i].Name = section.Name + sections[i].Size = section.VirtualSize + sections[i].Digest = ([32]byte)(sectionDigest.Sum(nil)) + sections[i].Measure = shouldMeasureSection(section.Name) + sections[i].MeasureOrder = sectionMeasureOrder(section.Name) + } + + sort.Slice(sections, func(i, j int) bool { + if sections[i].Measure != sections[j].Measure { + return sections[i].Measure + } + if sections[i].MeasureOrder == sections[j].MeasureOrder { + return sections[i].Name < sections[j].Name + } + return sections[i].MeasureOrder < sections[j].MeasureOrder + }) + + return sections, nil +} + +var ukiSections = []string{ + ".linux", + ".osrel", + ".cmdline", + ".initrd", + ".splash", + ".dtb", + ".uname", + ".sbat", + ".pcrsig", + ".pcrkey", +} + +func shouldMeasureSection(name string) bool { + for _, section := range ukiSections { + if name == section && name != ".pcrsig" { + return true + } + } + return false +} + +func sectionMeasureOrder(name string) int { + for i, section := range ukiSections { + if name == section { + return i + } + } + return -1 +} diff --git a/measured-boot/extract/extract_test.go b/measured-boot/extract/extract_test.go new file mode 100644 index 0000000..8492d13 --- /dev/null +++ b/measured-boot/extract/extract_test.go @@ -0,0 +1,233 @@ +package extract + +import ( + "bytes" + "io" + "testing" + + "github.com/edgelesssys/uplosi/measured-boot/internal/testdata" + "github.com/edgelesssys/uplosi/measured-boot/pesection" + + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestPeSectionReader(t *testing.T) { + assert := assert.New(t) + + // can read existing section ".uname" + peReader := bytes.NewReader(testdata.UKI()) + unameSectionReader, err := PeSectionReader(peReader, ".uname") + assert.NoError(err) + uname, err := io.ReadAll(unameSectionReader) + assert.NoError(err) + assert.Equal("0.0.0-100.constellation.fc0.x86_64", string(uname)) + + // fails to read non-existing section + _, err = PeSectionReader(peReader, ".non-existing") + assert.Error(err) + + // fails to read non-PE file + _, err = PeSectionReader(bytes.NewReader([]byte("not a PE file")), ".uname") + assert.Error(err) +} + +func TestPeFileSectionDigests(t *testing.T) { + assert := assert.New(t) + + // can calculate section digests + peReader := bytes.NewReader(testdata.UKI()) + sectionDigests, err := PeFileSectionDigests(peReader) + assert.NoError(err) + assert.Equal([]pesection.PESection{ + { + Name: ".linux", Size: 0x1f8, + Digest: [32]uint8{ + 0x01, 0xe5, 0xce, 0xe2, 0xd1, 0x8e, 0xaa, 0xce, + 0x36, 0xb5, 0xbc, 0x39, 0x4f, 0x70, 0x31, 0xaa, + 0xe1, 0x66, 0x8e, 0x4a, 0x7f, 0x7c, 0xc0, 0xe9, + 0x49, 0x52, 0x5e, 0xa6, 0x5c, 0x40, 0xf7, 0x95, + }, + Measure: true, MeasureOrder: 0, + }, + { + Name: ".osrel", Size: 0x2b0, + Digest: [32]uint8{ + 0x65, 0x83, 0x80, 0x1d, 0xa2, 0x9b, 0x3b, 0x74, + 0x0f, 0x0e, 0xb0, 0xc4, 0x27, 0xd5, 0xb8, 0x52, + 0x0b, 0xfb, 0xf7, 0xff, 0x63, 0x69, 0xc2, 0x2e, + 0xf2, 0xf4, 0xc4, 0x80, 0xf0, 0xea, 0x99, 0xfc, + }, + Measure: true, MeasureOrder: 1, + }, + { + Name: ".cmdline", + Size: 0x94, + Digest: [32]uint8{ + 0xf0, 0x47, 0xd0, 0x3a, 0x36, 0xf0, 0xde, 0x1f, + 0x77, 0x91, 0x6c, 0x2a, 0xab, 0x88, 0x77, 0xa9, + 0xd8, 0x80, 0xac, 0xf9, 0x17, 0x68, 0x3c, 0xc7, + 0x7b, 0x7c, 0x01, 0xdf, 0x18, 0xb1, 0x31, 0xc7, + }, + Measure: true, MeasureOrder: 2, + }, + { + Name: ".initrd", + Size: 0x12, + Digest: [32]uint8{ + 0x4e, 0x50, 0x30, 0x6a, 0x07, 0x84, 0x47, 0x1f, + 0x02, 0xde, 0x7e, 0x54, 0xd9, 0x0f, 0xdc, 0xa1, + 0x0e, 0x8e, 0x12, 0xec, 0xcc, 0x2d, 0x7a, 0x9d, + 0x97, 0x02, 0xf6, 0xe7, 0x38, 0xe1, 0xc2, 0xca, + }, + Measure: true, MeasureOrder: 3, + }, + { + Name: ".splash", + Size: 0x12, + Digest: [32]uint8{ + 0x36, 0xb5, 0xf4, 0x82, 0x37, 0x2e, 0x50, 0x49, + 0x83, 0x9d, 0x17, 0x6c, 0xf4, 0xd1, 0x4a, 0xcb, + 0xfd, 0xfe, 0xda, 0xc1, 0xbf, 0x77, 0xea, 0x0e, + 0xa4, 0xb1, 0x72, 0xa8, 0x76, 0xae, 0x2d, 0x2e, + }, + Measure: true, MeasureOrder: 4, + }, + { + Name: ".dtb", + Size: 0xf, + Digest: [32]uint8{ + 0x46, 0xa0, 0x01, 0x53, 0xca, 0xd9, 0x9d, 0x19, + 0x4a, 0xf1, 0x14, 0x48, 0x30, 0x5c, 0x8c, 0xa1, + 0x87, 0x2a, 0xba, 0xe9, 0x20, 0xee, 0x42, 0x3c, + 0x19, 0x35, 0x01, 0x05, 0x0f, 0x36, 0xe7, 0x8d, + }, + Measure: true, MeasureOrder: 5, + }, + { + Name: ".uname", Size: 0x22, + Digest: [32]uint8{ + 0x32, 0xd5, 0x9d, 0x99, 0x0e, 0x9c, 0x1f, 0x7d, + 0xa5, 0x54, 0xcb, 0x88, 0x8e, 0x32, 0x38, 0xac, + 0x61, 0x93, 0xe5, 0xe7, 0x23, 0x0f, 0x99, 0xb1, + 0x97, 0x13, 0x8d, 0xd7, 0x23, 0xc0, 0xeb, 0xb6, + }, + Measure: true, MeasureOrder: 6, + }, + { + Name: ".sbat", Size: 0x10, + Digest: [32]uint8{ + 0x66, 0x30, 0xfb, 0x7d, 0x5b, 0xaf, 0x9d, 0x6c, + 0xd5, 0x1c, 0x9a, 0xc9, 0x54, 0x10, 0xe6, 0x8a, + 0xa3, 0xfe, 0xdb, 0x4a, 0xdd, 0xd4, 0x2b, 0x34, + 0x0e, 0x47, 0x11, 0xe2, 0x3c, 0xcc, 0xd4, 0xb2, + }, + Measure: true, MeasureOrder: 7, + }, + { + Name: ".pcrkey", + Size: 0x12, + Digest: [32]uint8{ + 0x35, 0x4b, 0x67, 0xd5, 0xa3, 0xef, 0x2a, 0xff, + 0xda, 0xdb, 0x3d, 0xfc, 0x1f, 0x8b, 0xd0, 0xf6, + 0x69, 0xd0, 0x86, 0xa6, 0xd6, 0x7d, 0x5f, 0xee, + 0x88, 0xdb, 0x21, 0x90, 0xc4, 0xa7, 0x07, 0x26, + }, + Measure: true, MeasureOrder: 9, + }, + { + Name: ".data", + Size: 0x10, + Digest: [32]uint8{ + 0xc3, 0xde, 0x14, 0xca, 0x16, 0x45, 0x87, 0x5e, + 0x3b, 0xb0, 0xdd, 0xab, 0x9f, 0x60, 0x91, 0x46, + 0xf2, 0x1c, 0xc0, 0xeb, 0xd0, 0xea, 0x9b, 0x4f, + 0x22, 0xd3, 0x98, 0x40, 0xc0, 0xea, 0x29, 0xc5, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".dynamic", + Size: 0x13, + Digest: [32]uint8{ + 0x2b, 0x75, 0x29, 0xc8, 0x3a, 0x74, 0xbc, 0xb0, + 0xac, 0x63, 0x15, 0x18, 0xa1, 0x14, 0x95, 0x10, + 0x1a, 0x8d, 0x8e, 0x40, 0x69, 0x93, 0xed, 0x05, + 0xed, 0x8a, 0xcc, 0x2d, 0x88, 0xec, 0x13, 0x79, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".dynsym", + Size: 0x12, + Digest: [32]uint8{ + 0xb6, 0x0a, 0x7d, 0x65, 0x69, 0xeb, 0xa3, 0xd9, + 0x9e, 0xec, 0x13, 0x32, 0x57, 0x2b, 0x61, 0x19, + 0x32, 0x0b, 0x57, 0x1b, 0x43, 0xc1, 0x96, 0x75, + 0x37, 0x5a, 0x85, 0x76, 0xda, 0xf7, 0x81, 0x24, + }, + Measure: false, MeasureOrder: -1, + }, + { + //nolint:misspell + Name: ".rela", + Size: 0x10, + Digest: [32]uint8{ + 0x1c, 0xd6, 0xfb, 0x4f, 0xb8, 0x74, 0xfd, 0xb2, + 0xf3, 0xb7, 0xf5, 0x3d, 0xc1, 0x8c, 0x5b, 0x8e, + 0x5b, 0xa1, 0x4d, 0x00, 0x6c, 0x56, 0x41, 0x5e, + 0x9b, 0x8e, 0x22, 0x1d, 0xbf, 0x59, 0xdd, 0x9d, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".reloc", + Size: 0xa, + Digest: [32]uint8{ + 0x4f, 0xfa, 0xdb, 0x1d, 0xbd, 0xe9, 0x2d, 0xce, + 0x21, 0x37, 0xae, 0x1e, 0x24, 0x74, 0xad, 0x09, + 0xf2, 0x7b, 0x62, 0xe4, 0xbb, 0xa5, 0xcc, 0xc6, + 0x49, 0x0a, 0xb0, 0xda, 0x45, 0xfa, 0x45, 0xc3, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".sdmagic", Size: 0x2d, + Digest: [32]uint8{ + 0xc1, 0x02, 0x12, 0x0d, 0xe9, 0xfa, 0x62, 0x43, + 0xf2, 0x16, 0xdd, 0xb4, 0x58, 0x28, 0xe2, 0xa2, + 0xb6, 0x4a, 0x65, 0x82, 0x30, 0xd0, 0xca, 0xe6, + 0xc2, 0xf2, 0x98, 0x39, 0x67, 0xba, 0xbe, 0x95, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".text", Size: 0x10, + Digest: [32]uint8{ + 0xaf, 0x54, 0x41, 0x9a, 0x3f, 0xbe, 0x76, 0x0c, + 0xf7, 0xd3, 0x6a, 0x86, 0x37, 0xf0, 0x1d, 0x13, + 0xd4, 0x4b, 0xb5, 0xf3, 0x92, 0x15, 0xe2, 0x2e, + 0xad, 0x52, 0x15, 0x51, 0xfa, 0xe4, 0x2f, 0x2d, + }, + Measure: false, MeasureOrder: -1, + }, + { + Name: ".pcrsig", Size: 0x216, + Digest: [32]uint8{ + 0xcc, 0x41, 0xa5, 0x48, 0xbd, 0x02, 0x03, 0x17, + 0x49, 0x39, 0xf5, 0x0c, 0x3d, 0xf1, 0x77, 0x59, + 0xb8, 0x13, 0xb5, 0x31, 0xb0, 0x56, 0x3e, 0x91, + 0x20, 0x55, 0x6c, 0xf7, 0x25, 0x01, 0xa3, 0x26, + }, + Measure: false, MeasureOrder: 8, + }, + }, sectionDigests) + + // fails to read non-PE file + _, err = PeFileSectionDigests(bytes.NewReader([]byte("not a PE file"))) + assert.Error(err) +} diff --git a/measured-boot/internal/testdata/testdata.go b/measured-boot/internal/testdata/testdata.go new file mode 100644 index 0000000..b08b64f --- /dev/null +++ b/measured-boot/internal/testdata/testdata.go @@ -0,0 +1,11 @@ +package testdata + +import _ "embed" + +// UKI returns the UKI EFI binary. +func UKI() []byte { + return ukiEFI[:] +} + +//go:embed uki.efi +var ukiEFI []byte diff --git a/measured-boot/internal/testdata/uki.efi b/measured-boot/internal/testdata/uki.efi new file mode 100644 index 0000000..35abc33 Binary files /dev/null and b/measured-boot/internal/testdata/uki.efi differ diff --git a/measured-boot/measure/authentihash.go b/measured-boot/measure/authentihash.go new file mode 100644 index 0000000..eacc88c --- /dev/null +++ b/measured-boot/measure/authentihash.go @@ -0,0 +1,36 @@ +package measure + +import ( + "bytes" + "crypto" + "fmt" + "io" + + "github.com/foxboron/go-uefi/authenticode" +) + +// Authentihash returns the PE/COFF hash / Authentihash of a file. +func Authentihash(r io.Reader, h crypto.Hash) ([]byte, error) { + readerAt, err := getReaderAt(r) + if err != nil { + return nil, fmt.Errorf("failed to get readerAt: %v", err) + } + + bin, err := authenticode.Parse(readerAt) + if err != nil { + return nil, fmt.Errorf("failed to parse pe file: %v", err) + } + return bin.Hash(h), nil +} + +func getReaderAt(r io.Reader) (io.ReaderAt, error) { + if ra, ok := r.(io.ReaderAt); ok { + return ra, nil + } + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(r); err != nil { + return nil, fmt.Errorf("failed to read pe file: %v", err) + } + return bytes.NewReader(buf.Bytes()), nil +} diff --git a/measured-boot/measure/authentihash_test.go b/measured-boot/measure/authentihash_test.go new file mode 100644 index 0000000..77e44fb --- /dev/null +++ b/measured-boot/measure/authentihash_test.go @@ -0,0 +1,27 @@ +package measure + +import ( + "bytes" + "crypto" + "testing" + + "github.com/edgelesssys/uplosi/measured-boot/internal/testdata" + "github.com/stretchr/testify/assert" +) + +func TestPeSectionReader(t *testing.T) { + assert := assert.New(t) + + peReader := bytes.NewReader(testdata.UKI()) + digest, err := Authentihash(peReader, crypto.SHA256) + assert.NoError(err) + assert.Equal( + []byte{ + 0xd3, 0x43, 0xbe, 0x62, 0x65, 0xeb, 0x3e, 0x23, + 0xf7, 0x8b, 0x0a, 0xe0, 0x96, 0xbf, 0xf3, 0x34, + 0xe3, 0x7a, 0x76, 0x0a, 0xe8, 0x30, 0x73, 0x62, + 0x83, 0xf9, 0xb0, 0x26, 0x8e, 0xce, 0xdc, 0xf2, + }, + digest, + ) +} diff --git a/measured-boot/measure/measure_test.go b/measured-boot/measure/measure_test.go new file mode 100644 index 0000000..100df40 --- /dev/null +++ b/measured-boot/measure/measure_test.go @@ -0,0 +1,11 @@ +package measure + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/measured-boot/measure/pcr.go b/measured-boot/measure/pcr.go new file mode 100644 index 0000000..10c250c --- /dev/null +++ b/measured-boot/measure/pcr.go @@ -0,0 +1,132 @@ +package measure + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +// PCR256 is a 256-bit PCR value. +type PCR256 [32]byte + +// MarshalJSON implements json.Marshaler. +func (p PCR256) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("{\"expected\": \"%x\"}", p[:])), nil +} + +// Digest256 is a 256-bit digest value (sha256). +type Digest256 [32]byte + +// MarshalJSON implements json.Marshaler. +func (d Digest256) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf("\"%x\"", d[:])), nil +} + +// PCR256Bank is a map of PCR index to PCR256 value. +type PCR256Bank map[uint32]PCR256 + +// Event is a pcr extend event. +type Event struct { + PCRIndex uint32 + Digest Digest256 + Data []byte `json:",omitempty"` + Description string +} + +// EventLog is a list of events. +type EventLog struct { + Events []Event +} + +// Simulator is a TPM PCR simulator. +type Simulator struct { + Bank PCR256Bank `json:"measurements"` + EventLog EventLog +} + +// NewDefaultSimulator returns a new Simulator with default PCR values. +func NewDefaultSimulator() *Simulator { + return &Simulator{ + Bank: PCR256Bank{ + 4: ZeroPCR256(), + 8: ZeroPCR256(), + 9: ZeroPCR256(), + 11: ZeroPCR256(), + 12: ZeroPCR256(), + 13: ZeroPCR256(), + 15: ZeroPCR256(), + }, + } +} + +// ExtendPCR extends the PCR at index with the digest and data. +func (s *Simulator) ExtendPCR(index uint32, digest [32]byte, data []byte, description string) error { + hashCtx := sha256.New() + + old, ok := s.Bank[index] + if !ok { + return fmt.Errorf("PCR index %d not found", index) + } + + hashCtx.Write(old[:]) + hashCtx.Write(digest[:]) + newHash := hashCtx.Sum(nil) + s.Bank[index] = PCR256(newHash) + + var eventData []byte + if data != nil { + eventData = make([]byte, len(data)) + copy(eventData, data) + } + + s.EventLog.Events = append(s.EventLog.Events, Event{ + PCRIndex: index, + Digest: digest, + Data: eventData, + Description: description, + }) + + return nil +} + +// String returns a string representation of the simulator. +func (s *Simulator) String() string { + out := strings.Builder{} + + out.WriteString("PCR Bank:\n") + for i, pcr := range s.Bank { + out.WriteString(fmt.Sprintf("\tPCR %d: %x\n", i, pcr)) + } + + out.WriteString("Event Log:\n") + for _, event := range s.EventLog.Events { + out.WriteString(fmt.Sprintf("\tPCR %d: %x\n\t\t%s\n", event.PCRIndex, event.Digest, event.Description)) + } + + return out.String() +} + +// ZeroPCR256 returns a zeroed PCR256 value. +func ZeroPCR256() PCR256 { + return PCR256{} +} + +// EVEFIActionPCR256 returns the expected PCR256 value for EV_EFI_ACTION. +func EVEFIActionPCR256() PCR256 { + return PCR256{ + 0x3d, 0x67, 0x72, 0xb4, 0xf8, 0x4e, 0xd4, 0x75, + 0x95, 0xd7, 0x2a, 0x2c, 0x4c, 0x5f, 0xfd, 0x15, + 0xf5, 0xbb, 0x72, 0xc7, 0x50, 0x7f, 0xe2, 0x6f, + 0x2a, 0xae, 0xe2, 0xc6, 0x9d, 0x56, 0x33, 0xba, + } +} + +// EVSeparatorPCR256 returns the expected PCR256 value for EV_SEPARATOR. +func EVSeparatorPCR256() PCR256 { + return PCR256{ + 0xdf, 0x3f, 0x61, 0x98, 0x04, 0xa9, 0x2f, 0xdb, + 0x40, 0x57, 0x19, 0x2d, 0xc4, 0x3d, 0xd7, 0x48, + 0xea, 0x77, 0x8a, 0xdc, 0x52, 0xbc, 0x49, 0x8c, + 0xe8, 0x05, 0x24, 0xc0, 0x14, 0xb8, 0x11, 0x19, + } +} diff --git a/measured-boot/measure/pcr04.go b/measured-boot/measure/pcr04.go new file mode 100644 index 0000000..78d6b19 --- /dev/null +++ b/measured-boot/measure/pcr04.go @@ -0,0 +1,53 @@ +package measure + +import ( + "fmt" + "io" +) + +// EFIBootStage is a stage (bootloader) of the EFI boot process. +type EFIBootStage struct { + Name string + Digest [32]byte +} + +// DescribeBootStages prints a description of the EFIBootStages to a writer. +func DescribeBootStages(w io.Writer, bootStages []EFIBootStage) error { + if _, err := fmt.Fprintf(w, "EFI Boot Stages:\n"); err != nil { + return err + } + var maxNameLen int + for _, bootStage := range bootStages { + if len(bootStage.Name) > maxNameLen { + maxNameLen = len(bootStage.Name) + } + } + for i, bootStage := range bootStages { + if _, err := fmt.Fprintf(w, " Stage %d - %-*s:\t%x\n", i+1, maxNameLen, bootStage.Name, bootStage.Digest); err != nil { + return err + } + } + return nil +} + +// PredictPCR4 predicts the PCR4 value based on the EFIBootStages. +func PredictPCR4(simulator *Simulator, efiBootStages []EFIBootStage) error { + // TCG PC Client Platform Firmware Profile Family "2.0 Section" 7.2.4.4.a + if err := simulator.ExtendPCR(4, EVEFIActionPCR256(), nil, "EV_EFI_ACTION: Calling EFI Application from Boot Option"); err != nil { + return err + } + // TCG PC Client Platform Firmware Profile Family "2.0 Section" 7.2.4.4.b + if err := simulator.ExtendPCR(4, EVSeparatorPCR256(), []byte{0x00, 0x00, 0x00, 0x00}, "EV_SEPARATOR"); err != nil { + return err + } + + for i, efiBootStage := range efiBootStages { + // TCG PC Client Platform Firmware Profile Family "2.0 Section" 7.2.4.4.e + err := simulator.ExtendPCR(4, efiBootStage.Digest, nil, fmt.Sprintf("Boot Stage %d: %s", i+1, efiBootStage.Name)) + if err != nil { + return err + } + } + + return nil +} diff --git a/measured-boot/measure/pcr04_test.go b/measured-boot/measure/pcr04_test.go new file mode 100644 index 0000000..38bf637 --- /dev/null +++ b/measured-boot/measure/pcr04_test.go @@ -0,0 +1,45 @@ +package measure + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPredictPCR4(t *testing.T) { + assert := assert.New(t) + + sim := NewDefaultSimulator() + + bootstages := []EFIBootStage{ + { + Name: "stage0", + Digest: [32]byte{}, + }, + { + Name: "stage1", + Digest: [32]byte{ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + }, + }, + } + + out := bytes.NewBuffer(nil) + assert.NoError(DescribeBootStages(out, bootstages)) + assert.Equal("EFI Boot Stages:\n"+ + " Stage 1 - stage0:\t0000000000000000000000000000000000000000000000000000000000000000\n"+ + " Stage 2 - stage1:\t0101010101010101010101010101010101010101010101010101010101010101\n", + out.String()) + + assert.NoError(PredictPCR4(sim, bootstages)) + assert.Equal(PCR256{ + 0x22, 0x11, 0x6d, 0xee, 0x86, 0x1a, 0xa6, 0xb4, + 0x42, 0x42, 0xac, 0x46, 0x9e, 0xab, 0x24, 0xce, + 0xad, 0x34, 0x4d, 0x52, 0xc7, 0x71, 0x31, 0xf5, + 0x4a, 0xc1, 0xca, 0xc9, 0xd6, 0xa2, 0x40, 0x8e, + }, sim.Bank[4]) +} diff --git a/measured-boot/measure/pcr09.go b/measured-boot/measure/pcr09.go new file mode 100644 index 0000000..6a5c310 --- /dev/null +++ b/measured-boot/measure/pcr09.go @@ -0,0 +1,53 @@ +package measure + +import ( + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/text/encoding/unicode" +) + +// DescribeLinuxLoad2 describes the expected measurements for the Linux LOAD_FILE2 protocol. +func DescribeLinuxLoad2(w io.Writer, cmdline []byte, initrdDigest [32]byte) error { + if _, err := fmt.Fprintf(w, "Linux LOAD_FILE2 protocol:\n"); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " cmdline: %q\n", cmdline); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " initrd (digest %x)\n", initrdDigest); err != nil { + return err + } + return nil +} + +// PredictPCR9 predicts the PCR9 value based on the kernel command line and initrd. +func PredictPCR9(simulator *Simulator, cmdline []byte, initrdDigest [32]byte) error { + // Linux LOAD_FILE2 protocol + + // Some UKI builders will not null-terminate the command line, so we do it here. + // See: https://github.com/systemd/mkosi/blob/abef37482330e5b3fdc8ba72bff0bdcedbf6006d/mkosi/__init__.py#L2030 + if len(cmdline) == 0 || cmdline[len(cmdline)-1] != 0 { + cmdline = append(cmdline, 0) + } + + cmdlineUTF16LE, err := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder().Bytes(cmdline) + if err != nil { + return err + } + err = simulator.ExtendPCR(9, sha256.Sum256(cmdlineUTF16LE), cmdlineUTF16LE, fmt.Sprintf("EV_EVENT_TAG: Linux LOAD_FILE2 protocol: cmdline %q", cmdline)) + if err != nil { + return err + } + + // Linux LOAD_FILE2 protocol - efi_load_initrd + // https://github.com/torvalds/linux/blob/42dc814987c1feb6410904e58cfd4c36c4146150/drivers/firmware/efi/libstub/efi-stub-helper.c#L559 + // initrd is hashed as-is and measured + err = simulator.ExtendPCR(9, initrdDigest, nil, fmt.Sprintf("EV_EVENT_TAG: Linux LOAD_FILE2 protocol: initrd (digest %x)", initrdDigest)) + if err != nil { + return err + } + + return nil +} diff --git a/measured-boot/measure/pcr09_test.go b/measured-boot/measure/pcr09_test.go new file mode 100644 index 0000000..208fa02 --- /dev/null +++ b/measured-boot/measure/pcr09_test.go @@ -0,0 +1,32 @@ +package measure + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPredictPCR9(t *testing.T) { + assert := assert.New(t) + + sim := NewDefaultSimulator() + + cmdline := []byte("console=tty0\x00") + initrdDigest := [32]byte{} + + out := bytes.NewBuffer(nil) + assert.NoError(DescribeLinuxLoad2(out, cmdline, initrdDigest)) + assert.Equal("Linux LOAD_FILE2 protocol:\n"+ + " cmdline: \"console=tty0\\x00\"\n"+ + " initrd (digest 0000000000000000000000000000000000000000000000000000000000000000)\n", + out.String()) + + assert.NoError(PredictPCR9(sim, cmdline, initrdDigest)) + assert.Equal(PCR256{ + 0xeb, 0x4f, 0x7b, 0xca, 0x86, 0x58, 0x07, 0xd3, + 0x16, 0x3b, 0x95, 0x17, 0x4d, 0x6e, 0x66, 0xcf, + 0xc7, 0x4a, 0xcf, 0x8b, 0x93, 0x0a, 0x55, 0x3e, + 0x95, 0xec, 0x94, 0x66, 0x2c, 0xb6, 0xfa, 0xcd, + }, sim.Bank[9]) +} diff --git a/measured-boot/measure/pcr11.go b/measured-boot/measure/pcr11.go new file mode 100644 index 0000000..df77b39 --- /dev/null +++ b/measured-boot/measure/pcr11.go @@ -0,0 +1,62 @@ +package measure + +import ( + "crypto/sha256" + "fmt" + "io" + + "github.com/edgelesssys/uplosi/measured-boot/pesection" +) + +// DescribeUKISections describes the expected measurements for the UKI sections. +func DescribeUKISections(w io.Writer, ukiSections []pesection.PESection) error { + if _, err := fmt.Fprintf(w, "UKI sections:\n"); err != nil { + return err + } + + var maxNameLen int + for _, ukiSection := range ukiSections { + if len(ukiSection.Name) > maxNameLen { + maxNameLen = len(ukiSection.Name) + } + } + for i, ukiSection := range ukiSections { + if ukiSection.Measure { + if _, err := fmt.Fprintf(w, " Section %2d - %-*s (%10d bytes):\t%x, %x\n", i+1, maxNameLen, ukiSection.Name, ukiSection.Size, sha256.Sum256(ukiSection.NullTerminatedName()), ukiSection.Digest); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintf(w, " Section %2d - %-*s:\t%s\n", i+1, maxNameLen, ukiSection.Name, "not measured"); err != nil { + return err + } + } + return nil +} + +// PredictPCR11 predicts the PCR11 value based on the components of unified kernel images. +func PredictPCR11(simulator *Simulator, ukiSections []pesection.PESection) error { + for i, ukiSection := range ukiSections { + // systemd-stub documentation TPM PCR Notes + // https://github.com/systemd/systemd/blob/7c52d5236a3bc85db1755de6a458934be095cd1c/src/boot/efi/stub.c#L409-L441 + + if !ukiSection.Measure { + continue + } + + // first, measure the name + name := ukiSection.NullTerminatedName() + err := simulator.ExtendPCR(11, sha256.Sum256(name), name, fmt.Sprintf("EV_IPL: UKI section %d name: %s", i+1, ukiSection.Name)) + if err != nil { + return err + } + + // then, measure the data + err = simulator.ExtendPCR(11, ukiSection.Digest, nil, fmt.Sprintf("EV_IPL: UKI section %d data: %x", i+1, ukiSection.Digest)) + if err != nil { + return err + } + } + + return nil +} diff --git a/measured-boot/measure/pcr11_test.go b/measured-boot/measure/pcr11_test.go new file mode 100644 index 0000000..29c2178 --- /dev/null +++ b/measured-boot/measure/pcr11_test.go @@ -0,0 +1,55 @@ +package measure + +import ( + "bytes" + "testing" + + "github.com/edgelesssys/uplosi/measured-boot/pesection" + "github.com/stretchr/testify/assert" +) + +func TestPredictPCR11(t *testing.T) { + assert := assert.New(t) + + sim := NewDefaultSimulator() + + peSections := []pesection.PESection{ + { + Name: ".text", + Size: 100, + Digest: [32]byte{}, + }, + { + Name: ".linux", + Size: 100, + Digest: [32]byte{}, + Measure: true, + }, + { + Name: ".initrd", + Digest: [32]byte{ + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, + }, + Measure: true, + }, + } + + out := bytes.NewBuffer(nil) + assert.NoError(DescribeUKISections(out, peSections)) + assert.Equal("UKI sections:\n"+ + " Section 1 - .text :\tnot measured\n"+ + " Section 2 - .linux ( 100 bytes):\t0da293e37ad5511c59be47993769aacb91b243f7d010288e118dc90e95aaef5a, 0000000000000000000000000000000000000000000000000000000000000000\n"+ + " Section 3 - .initrd ( 0 bytes):\t15ee37e75f1e8d42080e91fdbbd2560780918c81fe3687ae6d15c472bbdaac75, 0101010101010101010101010101010101010101010101010101010101010101\n", + out.String()) + + assert.NoError(PredictPCR11(sim, peSections)) + assert.Equal(PCR256{ + 0x9d, 0xfe, 0x39, 0x9f, 0xcd, 0x44, 0x32, 0x63, + 0x9f, 0x0e, 0x20, 0xf4, 0x9d, 0xf8, 0x23, 0xaa, + 0x66, 0xb0, 0x95, 0xf0, 0x66, 0x4f, 0x0a, 0x4b, + 0x9f, 0xbd, 0xc1, 0x1e, 0xa6, 0x46, 0x83, 0xe2, + }, sim.Bank[11]) +} diff --git a/measured-boot/measure/pcr_test.go b/measured-boot/measure/pcr_test.go new file mode 100644 index 0000000..679b03e --- /dev/null +++ b/measured-boot/measure/pcr_test.go @@ -0,0 +1,43 @@ +package measure + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtendPCR(t *testing.T) { + assert := assert.New(t) + + sim := NewDefaultSimulator() + assert.Equal(ZeroPCR256(), sim.Bank[4]) + + assert.NoError(sim.ExtendPCR(4, EVSeparatorPCR256(), []byte{0x00, 0x00, 0x00, 0x00}, "EV_SEPARATOR")) + assert.Equal(PCR256{ + 0x3d, 0x45, 0x8c, 0xfe, 0x55, 0xcc, 0x03, 0xea, + 0x1f, 0x44, 0x3f, 0x15, 0x62, 0xbe, 0xec, 0x8d, + 0xf5, 0x1c, 0x75, 0xe1, 0x4a, 0x9f, 0xcf, 0x9a, + 0x72, 0x34, 0xa1, 0x3f, 0x19, 0x8e, 0x79, 0x69, + }, sim.Bank[4]) + + assert.NoError(sim.ExtendPCR(4, EVEFIActionPCR256(), nil, "EV_EFI_ACTION: Calling EFI Application from Boot Option")) + assert.Equal(PCR256{ + 0xdd, 0x50, 0xc8, 0xda, 0x0f, 0x89, 0x9f, 0x65, + 0x5b, 0x43, 0x05, 0xd2, 0x43, 0x86, 0x63, 0xc1, + 0xb3, 0xda, 0x6d, 0x19, 0x22, 0xa0, 0xc8, 0x22, + 0x65, 0x33, 0xac, 0x41, 0x7a, 0xbc, 0xd5, 0x23, + }, sim.Bank[4]) + + assert.Equal([]Event{ + { + PCRIndex: 0x4, Digest: Digest256(EVSeparatorPCR256()), + Data: []uint8{0x0, 0x0, 0x0, 0x0}, + Description: "EV_SEPARATOR", + }, + { + PCRIndex: 0x4, Digest: Digest256(EVEFIActionPCR256()), + Data: []uint8(nil), + Description: "EV_EFI_ACTION: Calling EFI Application from Boot Option", + }, + }, sim.EventLog.Events) +} diff --git a/measured-boot/measured-boot.go b/measured-boot/measured-boot.go new file mode 100644 index 0000000..b4c0881 --- /dev/null +++ b/measured-boot/measured-boot.go @@ -0,0 +1,171 @@ +package measuredboot + +import ( + "bytes" + "crypto" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/edgelesssys/uplosi/measured-boot/extract" + "github.com/edgelesssys/uplosi/measured-boot/measure" + "github.com/edgelesssys/uplosi/measured-boot/pesection" + "github.com/spf13/afero" +) + +const ( + // UkiPath is the path to the UKI EFI binary in the raw image. + UkiPath = "/boot/EFI/BOOT/BOOTX64.EFI" +) + +// PrecalculatePCRs precalculates the PCRs for a given image file and saves the PCR banks in the simulator. +func PrecalculatePCRs(fs afero.Fs, dissectToolchain, ukiPath, imageFile string) (*measure.Simulator, error) { + dir, err := afero.TempDir(fs, "", "con-measure") + if err != nil { + return nil, err + } + defer func() { _ = fs.RemoveAll(dir) }() + + simulator := measure.NewDefaultSimulator() + + // extract UKI from raw image + ukiFile := filepath.Join(dir, "uki.efi") + if err := extract.CopyFrom(dissectToolchain, imageFile, ukiPath, ukiFile); err != nil { + return nil, fmt.Errorf("failed to extract UKI: %v", err) + } + + // extract section digests from UKI + ukiReader, err := fs.Open(ukiFile) + if err != nil { + return nil, err + } + defer ukiReader.Close() + + ukiSections, err := extract.PeFileSectionDigests(ukiReader) + if err != nil { + return nil, fmt.Errorf("failed to extract UKI section digests: %v", err) + } + + if err := precalculatePCR4(simulator, fs, ukiFile); err != nil { + return nil, err + } + + if err := precalculatePCR9(simulator, fs, ukiFile); err != nil { + return nil, err + } + + if err := precalculatePCR11(simulator, ukiSections); err != nil { + return nil, err + } + + fmt.Fprintf(os.Stderr, "PCR[ 4]: %x\n", simulator.Bank[4]) + fmt.Fprintf(os.Stderr, "PCR[ 9]: %x\n", simulator.Bank[9]) + fmt.Fprintf(os.Stderr, "PCR[11]: %x\n", simulator.Bank[11]) + // TODO(malt3): with systemd-stub >= 254, PCR[12] will + // contain the "rendered" kernel command line, + // credentials, and sysexts. We should measure these + // values here. + // For now, we expect the PCR to be zero. + fmt.Fprintf(os.Stderr, "PCR[12]: %x\n", simulator.Bank[12]) + // PCR[13] would contain extension images for the initrd + // We enforce the absence of extension images by + // expecting PCR[13] to be zero. + fmt.Fprintf(os.Stderr, "PCR[13]: %x\n", simulator.Bank[13]) + // PCR[15] can be used to measure from userspace (systemd-pcrphase and others) + // We enforce the absence of userspace measurements by + // expecting PCR[15] to be zero at boot. + fmt.Fprintf(os.Stderr, "PCR[15]: %x\n", simulator.Bank[15]) + + return simulator, nil +} + +func measurePE(fs afero.Fs, peFile string) ([]byte, error) { + f, err := fs.Open(peFile) + if err != nil { + return nil, err + } + defer f.Close() + + return measure.Authentihash(f, crypto.SHA256) +} + +func precalculatePCR4(simulator *measure.Simulator, fs afero.Fs, ukiFile string) error { + ukiMeasurement, err := measurePE(fs, ukiFile) + if err != nil { + return fmt.Errorf("failed to measure UKI: %v", err) + } + + ukiPe, err := fs.Open(ukiFile) + if err != nil { + return err + } + defer ukiPe.Close() + linuxSectionReader, err := extract.PeSectionReader(ukiPe, ".linux") + if err != nil { + return fmt.Errorf("uki does not contain linux kernel image: %v", err) + } + linuxMeasurement, err := measure.Authentihash(linuxSectionReader, crypto.SHA256) + if err != nil { + return fmt.Errorf("failed to measure linux kernel image: %v", err) + } + + bootStages := []measure.EFIBootStage{ + {Name: "Unified Kernel Image (UKI)", Digest: measure.PCR256(ukiMeasurement)}, + {Name: "Linux", Digest: measure.PCR256(linuxMeasurement)}, + } + + if err := measure.DescribeBootStages(os.Stderr, bootStages); err != nil { + return err + } + + return measure.PredictPCR4(simulator, bootStages) +} + +func precalculatePCR9(simulator *measure.Simulator, fs afero.Fs, ukiFile string) error { + // load cmdline and initrd from UKI + + ukiPe, err := fs.Open(ukiFile) + if err != nil { + return err + } + defer ukiPe.Close() + + cmdlineSectionReader, err := extract.PeSectionReader(ukiPe, ".cmdline") + if err != nil { + return fmt.Errorf("uki does not contain cmdline: %v", err) + } + + cmdline := new(bytes.Buffer) + if _, err := cmdline.ReadFrom(cmdlineSectionReader); err != nil { + return err + } + + initrdSectionReader, err := extract.PeSectionReader(ukiPe, ".initrd") + if err != nil { + return fmt.Errorf("uki does not contain initrd: %v", err) + } + + initrdDigest := sha256.New() + if _, err := io.Copy(initrdDigest, initrdSectionReader); err != nil { + return err + } + + cmdlineBytes := cmdline.Bytes() + initrdDigestBytes := [32]byte(initrdDigest.Sum(nil)) + + if err := measure.DescribeLinuxLoad2(os.Stderr, cmdlineBytes, initrdDigestBytes); err != nil { + return err + } + + return measure.PredictPCR9(simulator, cmdlineBytes, initrdDigestBytes) +} + +func precalculatePCR11(simulator *measure.Simulator, ukiSections []pesection.PESection) error { + if err := measure.DescribeUKISections(os.Stderr, ukiSections); err != nil { + return err + } + + return measure.PredictPCR11(simulator, ukiSections) +} diff --git a/measured-boot/pesection/pesection.go b/measured-boot/pesection/pesection.go new file mode 100644 index 0000000..836da7a --- /dev/null +++ b/measured-boot/pesection/pesection.go @@ -0,0 +1,18 @@ +package pesection + +// PESection describes a PE section. +type PESection struct { + Name string + Size uint32 + Digest [32]byte + Measure bool + MeasureOrder int +} + +// NullTerminatedName returns the name of the section with a null terminator. +func (u PESection) NullTerminatedName() []byte { + if len(u.Name) > 0 && u.Name[len(u.Name)-1] == 0x00 { + return []byte(u.Name) + } + return append([]byte(u.Name), 0x00) +} diff --git a/measurements.go b/measurements.go new file mode 100644 index 0000000..9e63e73 --- /dev/null +++ b/measurements.go @@ -0,0 +1,104 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + measuredboot "github.com/edgelesssys/uplosi/measured-boot" + "github.com/edgelesssys/uplosi/measured-boot/measure" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func newMeasurementsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "measurements ", + Short: "Precalculate TPM PCR measurements for an image. Requires 'systemd-dissect' to be in the PATH.", + Args: cobra.ExactArgs(1), + RunE: runMeasurements, + } + cmd.Flags().StringP("output-file", "o", "", "Output file for the precalculated measurements") + cmd.Flags().StringP("uki-path", "u", measuredboot.UkiPath, "Path to the UKI file in the image") + + return cmd +} + +func runMeasurements(cmd *cobra.Command, args []string) error { + flags, err := parseMeasurementsFlags(cmd) + if err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + + fs := afero.NewOsFs() + dissectToolchain := loadToolchain("DISSECT_TOOLCHAIN", "systemd-dissect") + + simulator, err := measuredboot.PrecalculatePCRs(fs, dissectToolchain, flags.ukiPath, args[0]) + if err != nil { + return fmt.Errorf("precalculating PCRs: %w", err) + } + + if flags.outputFile != "" { + if err := writeOutput(fs, flags.outputFile, simulator); err != nil { + return fmt.Errorf("writing output: %w", err) + } + cmd.Printf("Wrote precalculated measurements to %s\n", flags.outputFile) + } + + return nil +} + +type measurementsFlags struct { + outputFile string + ukiPath string +} + +func parseMeasurementsFlags(cmd *cobra.Command) (*measurementsFlags, error) { + outputFile, err := cmd.Flags().GetString("output-file") + if err != nil { + return nil, fmt.Errorf("getting output-file flag: %w", err) + } + ukiPath, err := cmd.Flags().GetString("uki-path") + if err != nil { + return nil, fmt.Errorf("getting uki-path flag: %w", err) + } + return &measurementsFlags{ + outputFile: outputFile, + ukiPath: ukiPath, + }, nil +} + +func loadToolchain(key, fallback string) string { + toolchain := os.Getenv(key) + if toolchain == "" { + toolchain = fallback + } + toolchain, err := exec.LookPath(toolchain) + if err != nil { + return "" + } + + absolutePath, err := filepath.Abs(toolchain) + if err != nil { + return "" + } + return absolutePath +} + +func writeOutput(fs afero.Fs, outputFile string, simulator *measure.Simulator) error { + out, err := fs.Create(outputFile) + if err != nil { + return err + } + defer out.Close() + + return json.NewEncoder(out).Encode(simulator) +} diff --git a/upload.go b/upload.go new file mode 100644 index 0000000..634850a --- /dev/null +++ b/upload.go @@ -0,0 +1,309 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "github.com/BurntSushi/toml" + "github.com/edgelesssys/uplosi/aws" + "github.com/edgelesssys/uplosi/azure" + "github.com/edgelesssys/uplosi/config" + "github.com/edgelesssys/uplosi/gcp" + "github.com/edgelesssys/uplosi/openstack" + "github.com/spf13/cobra" + "golang.org/x/mod/semver" +) + +const ( + configName = "uplosi.conf" + configDir = "uplosi.conf.d" +) + +func newUploadCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "upload ", + Short: "Upload an image to a cloud provider", + Args: cobra.ExactArgs(1), + RunE: runUpload, + } + cmd.Flags().BoolP("increment-version", "i", false, "increment version number after upload") + cmd.Flags().StringSlice("enable-variant-glob", []string{"*"}, "list of variant name globs to enable") + cmd.Flags().StringSlice("disable-variant-glob", nil, "list of variant name globs to disable") + cmd.Flags().StringP("config", "c", "", fmt.Sprintf("path to directory %s and %s resides in", configName, configDir)) + + return cmd +} + +func runUpload(cmd *cobra.Command, args []string) error { + logger := log.New(cmd.ErrOrStderr(), "", log.LstdFlags) + imagePath := args[0] + + flags, err := parseUploadFlags(cmd) + if err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + + conf, err := parseConfigFiles(flags.configPath) + if err != nil { + return fmt.Errorf("parsing config files: %w", err) + } + + versionFiles := map[string][]byte{} + versionFileLookup := func(name string) ([]byte, error) { + if _, ok := versionFiles[name]; !ok { + ver, err := os.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("reading version file: %w", err) + } + versionFiles[name] = ver + } + return versionFiles[name], nil + } + + allRefs := []string{} + err = conf.ForEach( + func(name string, cfg config.Config) error { + refs, err := uploadVariant(cmd.Context(), imagePath, name, cfg, logger) + if err != nil { + return err + } + allRefs = append(allRefs, refs...) + return nil + }, + versionFileLookup, + func(name string) bool { + return filterGlobAny(flags.enableVariantGlobs, name) + }, + func(name string) bool { + return !filterGlobAny(flags.disableVariantGlobs, name) + }, + ) + if err != nil { + return fmt.Errorf("uploading variants: %w", err) + } + + for _, ref := range allRefs { + fmt.Println(ref) + } + + if !flags.incrementVersion { + return nil + } + if len(versionFiles) == 0 { + return errors.New("increment-version flag set but no version files found") + } + for versionFileName, version := range versionFiles { + newVer, err := incrementSemver(strings.TrimSpace(string(version))) + if err != nil { + return fmt.Errorf("incrementing semver: %w", err) + } + if err := writeVersionFile(versionFileName, []byte(newVer)); err != nil { + return fmt.Errorf("writing version file: %w", err) + } + } + return nil +} + +func uploadVariant(ctx context.Context, imagePath, variant string, config config.Config, logger *log.Logger) ([]string, error) { + var prepper Prepper + var upload Uploader + var err error + + if len(variant) > 0 { + log.Println("Uploading variant", variant) + } + + switch strings.ToLower(config.Provider) { + case "aws": + prepper = &aws.Prepper{} + upload, err = aws.NewUploader(config, logger) + if err != nil { + return nil, fmt.Errorf("creating aws uploader: %w", err) + } + case "azure": + prepper = &azure.Prepper{} + upload, err = azure.NewUploader(config, logger) + if err != nil { + return nil, fmt.Errorf("creating azure uploader: %w", err) + } + case "gcp": + prepper = &gcp.Prepper{} + upload, err = gcp.NewUploader(config, logger) + if err != nil { + return nil, fmt.Errorf("creating gcp uploader: %w", err) + } + case "openstack": + prepper = &openstack.Prepper{} + upload, err = openstack.NewUploader(config, logger) + if err != nil { + return nil, fmt.Errorf("creating openstack uploader: %w", err) + } + default: + return nil, fmt.Errorf("unknown provider: %s", config.Provider) + } + + tmpDir, err := os.MkdirTemp("", "uplosi-") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + imagePath, err = prepper.Prepare(ctx, imagePath, tmpDir) + if err != nil { + return nil, fmt.Errorf("preparing image: %w", err) + } + image, err := os.Open(imagePath) + if err != nil { + return nil, fmt.Errorf("opening image: %w", err) + } + defer image.Close() + imageFi, err := image.Stat() + if err != nil { + return nil, fmt.Errorf("getting image stats: %w", err) + } + + refs, err := upload.Upload(ctx, image, imageFi.Size()) + if err != nil { + return nil, fmt.Errorf("uploading image: %w", err) + } + + return refs, nil +} + +type uploadFlags struct { + incrementVersion bool + enableVariantGlobs []string + disableVariantGlobs []string + configPath string +} + +func parseUploadFlags(cmd *cobra.Command) (*uploadFlags, error) { + incrementVersion, err := cmd.Flags().GetBool("increment-version") + if err != nil { + return nil, fmt.Errorf("getting increment-version flag: %w", err) + } + enableVariantGlobs, err := cmd.Flags().GetStringSlice("enable-variant-glob") + if err != nil { + return nil, fmt.Errorf("getting enable-variant-glob flag: %w", err) + } + disableVariantGlobs, err := cmd.Flags().GetStringSlice("disable-variant-glob") + if err != nil { + return nil, fmt.Errorf("getting disable-variant-glob flag: %w", err) + } + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return nil, fmt.Errorf("getting config flag: %w", err) + } + return &uploadFlags{ + incrementVersion: incrementVersion, + enableVariantGlobs: enableVariantGlobs, + disableVariantGlobs: disableVariantGlobs, + configPath: configPath, + }, nil +} + +func filterGlobAny(globs []string, name string) bool { + for _, glob := range globs { + if ok, _ := filepath.Match(glob, name); ok { + return true + } + } + return false +} + +func readTOMLFile(path string, data any) error { + configFile, err := os.OpenFile(path, os.O_RDONLY, os.ModeAppend) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer configFile.Close() + if _, err := toml.NewDecoder(configFile).Decode(data); err != nil { + return fmt.Errorf("decoding file: %w", err) + } + return nil +} + +func writeVersionFile(path string, data []byte) error { + versionFile, err := os.OpenFile(path, os.O_WRONLY, os.ModeAppend) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer versionFile.Close() + if _, err := versionFile.Write(data); err != nil { + return fmt.Errorf("writing file: %w", err) + } + return nil +} + +type Prepper interface { + Prepare(ctx context.Context, imagePath, tmpDir string) (string, error) +} + +type Uploader interface { + Upload(ctx context.Context, image io.ReadSeeker, size int64) (refs []string, retErr error) +} + +func parseConfigFiles(configPath string) (*config.ConfigFile, error) { + configLocation := path.Join(configPath, configName) + configDirLocation := path.Join(configPath, configDir) + + var conf config.ConfigFile + if err := readTOMLFile(configLocation, &conf); err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + dirEntries, err := os.ReadDir(configDirLocation) + if os.IsNotExist(err) { + return &conf, nil + } + if err != nil { + return nil, fmt.Errorf("reading config dir: %w", err) + } + for _, dirEntry := range dirEntries { + var cfgOverlay config.ConfigFile + if dirEntry.IsDir() { + continue + } + if filepath.Ext(dirEntry.Name()) != ".conf" { + continue + } + if err := readTOMLFile(filepath.Join(configDir, dirEntry.Name()), &cfgOverlay); err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + if err := conf.Merge(cfgOverlay); err != nil { + return nil, fmt.Errorf("merging config: %w", err) + } + } + return &conf, nil +} + +func incrementSemver(version string) (string, error) { + canonical := strings.TrimPrefix(semver.Canonical("v"+version), "v") + parts := strings.Split(canonical, ".") + if len(parts) != 3 { + return "", fmt.Errorf("splitting canonical version: %s, %v", canonical, parts) + } + + patch := parts[2] + patchNum, err := strconv.Atoi(patch) + if err != nil { + return "", fmt.Errorf("converting patch number: %w", err) + } + + patchNum++ + return fmt.Sprintf("%s.%s.%d", parts[0], parts[1], patchNum), nil +} diff --git a/cmd_test.go b/upload_test.go similarity index 100% rename from cmd_test.go rename to upload_test.go