diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..02a97839c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +indent_style = space +indent_size = 2 +tab_width = 2 + +[*.go] +indent_size = 4 +tab_width = 4 +indent_style = tab +# required for multiline strings in test cases +trim_trailing_whitespace = false + +[Makefile] +indent_size = 4 +tab_width = 4 +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9dec782aa..918773a9f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Ask a question - url: https://github.com/notaryproject/nv2/discussions + url: https://github.com/notaryproject/notation/discussions about: Ask questions and discuss with other community members \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..715f5b432 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + # Workflow files stored in the + # default location of `.github/workflows` + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..4d573eba0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: build + +on: + push: + pull_request: + +jobs: + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + name: Continuous Integration + runs-on: ubuntu-20.04 + strategy: + matrix: + go-version: [1.17] + fail-fast: true + steps: + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Check out code + uses: actions/checkout@v2 + - name: Cache Go modules + uses: actions/cache@v2 + id: go-mod-cache + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Get dependencies + run: make download + - name: Build + run: make build diff --git a/.github/workflows/release-github.yml b/.github/workflows/release-github.yml new file mode 100644 index 000000000..70a3401f6 --- /dev/null +++ b/.github/workflows/release-github.yml @@ -0,0 +1,30 @@ +name: release-github + +on: + push: + tags: + - v* + +jobs: + build: + name: Release Notation Binaries + runs-on: ubuntu-20.04 + strategy: + matrix: + go-version: [1.17] + fail-fast: true + steps: + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout + uses: actions/checkout@v2 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_USER_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..db20121cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# VS Code +.vscode + +# Custom +bin/ +vendor/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 000000000..e57a34eb8 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,61 @@ +builds: + - main: ./cmd/notation + id: notation + binary: notation + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w -X {{.ModulePath}}/internal/version.Version={{.Version}} -X {{.ModulePath}}/internal/version.BuildMetadata= + - main: ./cmd/docker-notation + id: docker-notation + binary: docker-notation + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w -X {{.ModulePath}}/internal/version.Version={{.Version}} -X {{.ModulePath}}/internal/version.BuildMetadata= + - main: ./cmd/docker-generate + id: docker-generate + binary: docker-generate + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip + files: + - LICENSE +release: + prerelease: auto diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a691e2c89 --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +MODULE = github.com/notaryproject/notation +DOCKER_PLUGINS = docker-generate docker-notation +COMMANDS = notation $(DOCKER_PLUGINS) +GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null) +BUILD_METADATA = +ifeq ($(GIT_TAG),) # unreleased build + GIT_COMMIT = $(shell git rev-parse HEAD) + GIT_STATUS = $(shell test -n "`git status --porcelain`" && echo "dirty" || echo "unreleased") + BUILD_METADATA = $(GIT_COMMIT).$(GIT_STATUS) +endif +LDFLAGS = -X $(MODULE)/internal/version.BuildMetadata=$(BUILD_METADATA) +GO_BUILD_FLAGS = --ldflags="$(LDFLAGS)" + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' + +.PHONY: all +all: build + +.PHONY: FORCE +FORCE: + +bin/%: cmd/% FORCE + go build $(GO_BUILD_FLAGS) -o $@ ./$< + +.PHONY: download +download: ## download dependencies via go mod + go mod download + +.PHONY: build +build: $(addprefix bin/,$(COMMANDS)) ## builds binaries + +.PHONY: clean +clean: + git status --ignored --short | grep '^!! ' | sed 's/!! //' | xargs rm -rf + +.PHONY: check-line-endings +check-line-endings: ## check line endings + ! find cmd pkg internal -name "*.go" -type f -exec file "{}" ";" | grep CRLF + +.PHONY: fix-line-endings +fix-line-endings: ## fix line endings + find cmd pkg internal -type f -name "*.go" -exec sed -i -e "s/\r//g" {} + + +.PHONY: vendor +vendor: ## vendores the go modules + GO111MODULE=on go mod vendor + +.PHONY: install +install: install-notation install-docker-plugins ## install the notation cli and docker plugins + +.PHONY: install-notation +install-notation: bin/notation ## installs the notation cli + cp $< ~/bin/ + +.PHONY: install-docker-% +install-docker-%: bin/docker-% + cp $< ~/.docker/cli-plugins/ + +.PHONY: install-docker-plugins +install-docker-plugins: $(addprefix install-,$(DOCKER_PLUGINS)) ## installs the docker plugins + cp $(addprefix bin/,$(DOCKER_PLUGINS)) ~/.docker/cli-plugins/ diff --git a/building.md b/building.md new file mode 100644 index 000000000..65d54b6b5 --- /dev/null +++ b/building.md @@ -0,0 +1,33 @@ +# Building Notation + +The notation repo contains the following: + +- `notation` - A CLI for signing and verifying artifacts with Notation +- `docker-generate` - Extends docker with `docker generate` to create locally persisted manifest for signing, without having to push to a registry. +- `docker-notation` - Extends docker with `docker notation` to enable, sign and verify Notation signatures. + +Building above binaries require [golang](https://golang.org/dl/) with version `>= 1.17`. + +## Windows with WSL + +- Build the binaries, installing them to: + - `~/bin/notation` + - `~/.docker/cli-plugins/docker-generate` + - `~/.docker/cli-plugins/docker-notation` + ```sh + git clone https://github.com/notaryproject/notation.git + cd notation + make install + ``` +- Verify binaries are installed + ```sh + docker --help + # look for + Management Commands: + generate* Generate artifacts (CNCF Notary Project, 0.1.0) + notation* Manage signatures on Docker images (CNCF Notary Project, 0.5.3-alpha) + + which notation + # output + /home//bin/notation + ``` diff --git a/cmd/docker-generate/generate.go b/cmd/docker-generate/generate.go new file mode 100644 index 000000000..32ceecbc7 --- /dev/null +++ b/cmd/docker-generate/generate.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/urfave/cli/v2" +) + +var generateCommand = &cli.Command{ + Name: "generate", + Subcommands: []*cli.Command{ + manifestCommand, + }, +} diff --git a/cmd/docker-generate/main.go b/cmd/docker-generate/main.go new file mode 100644 index 000000000..86fef92cc --- /dev/null +++ b/cmd/docker-generate/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "os" + + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "docker", + Commands: []*cli.Command{ + generateCommand, + metadataCommand, + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/docker-generate/manifest.go b/cmd/docker-generate/manifest.go new file mode 100644 index 000000000..7c717b9a6 --- /dev/null +++ b/cmd/docker-generate/manifest.go @@ -0,0 +1,66 @@ +package main + +import ( + "io" + "os" + "os/exec" + + "github.com/notaryproject/notation/pkg/docker" + "github.com/urfave/cli/v2" +) + +var manifestCommand = &cli.Command{ + Name: "manifest", + Usage: "generates the manifest of a docker image", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "write to a file instead of stdout", + }, + }, + Action: generateManifest, +} + +func generateManifest(ctx *cli.Context) error { + var reader io.Reader + if reference := ctx.Args().First(); reference != "" { + cmd := exec.Command("docker", "save", reference) + cmd.Stderr = os.Stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + reader = stdout + if err := cmd.Start(); err != nil { + return err + } + } else { + reader = os.Stdin + } + + var writer io.Writer + if output := ctx.String("output"); output != "" { + file, err := os.Create(output) + if err != nil { + return err + } + defer file.Close() + writer = file + } else { + writer = os.Stdout + } + + manifest, err := docker.GenerateSchema2FromDockerSave(reader) + if err != nil { + return err + } + _, payload, err := manifest.Payload() + if err != nil { + return err + } + + _, err = writer.Write(payload) + return err +} diff --git a/cmd/docker-generate/metadata.go b/cmd/docker-generate/metadata.go new file mode 100644 index 000000000..c7ad710f0 --- /dev/null +++ b/cmd/docker-generate/metadata.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/notaryproject/notation/internal/docker" + "github.com/urfave/cli/v2" +) + +var pluginMetadata = docker.PluginMetadata{ + SchemaVersion: "0.1.0", + Vendor: "CNCF Notary Project", + Version: "0.1.1", + ShortDescription: "Generate artifacts", + URL: "https://github.com/notaryproject/notation", + Experimental: true, +} + +var metadataCommand = &cli.Command{ + Name: docker.PluginMetadataCommandName, + Action: func(ctx *cli.Context) error { + writer := json.NewEncoder(os.Stdout) + return writer.Encode(pluginMetadata) + }, + Hidden: true, +} diff --git a/cmd/docker-notation/crypto/service.go b/cmd/docker-notation/crypto/service.go new file mode 100644 index 000000000..784bd14cd --- /dev/null +++ b/cmd/docker-notation/crypto/service.go @@ -0,0 +1,40 @@ +package crypto + +import ( + "crypto/x509" + + "github.com/docker/libtrust" + "github.com/notaryproject/notation-go-lib" + x509n "github.com/notaryproject/notation-go-lib/signature/x509" + "github.com/notaryproject/notation-go-lib/simple" +) + +// GetSigningService returns a signing service +func GetSigningService(keyPath string, certPaths ...string) (notation.SigningService, error) { + var ( + key libtrust.PrivateKey + commonCerts []*x509.Certificate + rootCerts *x509.CertPool + err error + ) + if keyPath != "" { + key, err = x509n.ReadPrivateKeyFile(keyPath) + if err != nil { + return nil, err + } + } + if len(certPaths) != 0 { + rootCerts = x509.NewCertPool() + for _, certPath := range certPaths { + certs, err := x509n.ReadCertificateFile(certPath) + if err != nil { + return nil, err + } + commonCerts = append(commonCerts, certs...) + for _, cert := range certs { + rootCerts.AddCert(cert) + } + } + } + return simple.NewSigningService(key, commonCerts, commonCerts, rootCerts) +} diff --git a/cmd/docker-notation/docker/credential.go b/cmd/docker-notation/docker/credential.go new file mode 100644 index 000000000..18b303bd5 --- /dev/null +++ b/cmd/docker-notation/docker/credential.go @@ -0,0 +1,24 @@ +package docker + +import ( + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/credentials" +) + +// BasicCredentialFromDockerConfig fetches the credentials for basic auth +// from docker config +func BasicCredentialFromDockerConfig(hostname string) (string, string, error) { + cfg, err := config.Load(config.Dir()) + if err != nil { + return "", "", err + } + if !cfg.ContainsAuth() { + cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) + } + + auth, err := cfg.GetAuthConfig(hostname) + if err != nil { + return "", "", err + } + return auth.Username, auth.Password, nil +} diff --git a/cmd/docker-notation/docker/manifest.go b/cmd/docker-notation/docker/manifest.go new file mode 100644 index 000000000..c0c92df5a --- /dev/null +++ b/cmd/docker-notation/docker/manifest.go @@ -0,0 +1,60 @@ +package docker + +import ( + "context" + "net" + "os/exec" + + "github.com/distribution/distribution/v3/manifest/schema2" + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/docker" + "github.com/notaryproject/notation/pkg/registry" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// GenerateManifest generate manifest from docker save +func GenerateManifest(reference string) ([]byte, error) { + cmd := exec.Command("docker", "save", reference) + reader, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + manifest, err := docker.GenerateSchema2FromDockerSave(reader) + if err != nil { + return nil, err + } + _, payload, err := manifest.Payload() + return payload, err +} + +// GenerateManifestOCIDescriptor generate manifest descriptor from docker save +func GenerateManifestOCIDescriptor(reference string) (ocispec.Descriptor, error) { + manifest, err := GenerateManifest(reference) + if err != nil { + return ocispec.Descriptor{}, err + } + return ocispec.Descriptor{ + MediaType: schema2.MediaTypeManifest, + Digest: digest.FromBytes(manifest), + Size: int64(len(manifest)), + }, nil +} + +// GetManifestOCIDescriptor get manifest descriptor from remote registry +func GetManifestOCIDescriptor(ctx context.Context, ref registry.Reference) (ocispec.Descriptor, error) { + tr, err := Transport(ref.Registry) + if err != nil { + return ocispec.Descriptor{}, err + } + insecure := config.IsRegistryInsecure(ref.Registry) + if host, _, _ := net.SplitHostPort(ref.Registry); host == "localhost" { + insecure = true + } + client := registry.NewClient(tr, insecure) + return client.GetManifestDescriptor(ref) +} diff --git a/cmd/docker-notation/docker/network.go b/cmd/docker-notation/docker/network.go new file mode 100644 index 000000000..5ed12ad48 --- /dev/null +++ b/cmd/docker-notation/docker/network.go @@ -0,0 +1,20 @@ +package docker + +import ( + "net/http" + + "github.com/notaryproject/notation/pkg/registry" +) + +// Transport returns the configured round tripper for a host +func Transport(hostname string) (http.RoundTripper, error) { + tr := http.DefaultTransport + username, password, err := BasicCredentialFromDockerConfig(hostname) + if err != nil { + return nil, err + } + if username == "" { + return tr, nil + } + return registry.NewAuthtransport(tr, username, password), nil +} diff --git a/cmd/docker-notation/docker/signature.go b/cmd/docker-notation/docker/signature.go new file mode 100644 index 000000000..6f38c1e2f --- /dev/null +++ b/cmd/docker-notation/docker/signature.go @@ -0,0 +1,33 @@ +package docker + +import ( + "context" + "net" + + "github.com/distribution/distribution/v3/reference" + "github.com/notaryproject/notation-go-lib" + "github.com/notaryproject/notation-go-lib/registry" + "github.com/notaryproject/notation/pkg/config" +) + +// GetSignatureRepository returns a signature repository +func GetSignatureRepository(ctx context.Context, ref string) (notation.SignatureRepository, error) { + named, err := reference.ParseNamed(ref) + if err != nil { + return nil, err + } + hostname, repository := reference.SplitHostname(named) + + tr, err := Transport(hostname) + if err != nil { + return nil, err + } + + insecure := config.IsRegistryInsecure(hostname) + if host, _, _ := net.SplitHostPort(hostname); host == "localhost" { + insecure = true + } + client := registry.NewClient(tr, hostname, insecure) + + return client.Repository(ctx, repository), nil +} diff --git a/cmd/docker-notation/main.go b/cmd/docker-notation/main.go new file mode 100644 index 000000000..cf38128a1 --- /dev/null +++ b/cmd/docker-notation/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "os" + + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "docker", + Commands: []*cli.Command{ + notationCommand, + metadataCommand, + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/docker-notation/metadata.go b/cmd/docker-notation/metadata.go new file mode 100644 index 000000000..e5421067f --- /dev/null +++ b/cmd/docker-notation/metadata.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/notaryproject/notation/internal/docker" + "github.com/notaryproject/notation/internal/version" + "github.com/urfave/cli/v2" +) + +var pluginMetadata = docker.PluginMetadata{ + SchemaVersion: "0.1.0", + Vendor: "CNCF Notary Project", + Version: version.GetVersion(), + ShortDescription: "Manage signatures on Docker images", + URL: "https://github.com/notaryproject/notation", +} + +var metadataCommand = &cli.Command{ + Name: docker.PluginMetadataCommandName, + Action: func(ctx *cli.Context) error { + writer := json.NewEncoder(os.Stdout) + return writer.Encode(pluginMetadata) + }, + Hidden: true, +} diff --git a/cmd/docker-notation/notation.go b/cmd/docker-notation/notation.go new file mode 100644 index 000000000..7cbb396b2 --- /dev/null +++ b/cmd/docker-notation/notation.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/urfave/cli/v2" +) + +var notationCommand = &cli.Command{ + Name: "notation", + Usage: pluginMetadata.ShortDescription, + Subcommands: []*cli.Command{ + pullCommand, + pushCommand, + signCommand, + }, +} diff --git a/cmd/docker-notation/pull.go b/cmd/docker-notation/pull.go new file mode 100644 index 000000000..28baa914f --- /dev/null +++ b/cmd/docker-notation/pull.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/notaryproject/notation-go-lib" + "github.com/notaryproject/notation/cmd/docker-notation/docker" + "github.com/notaryproject/notation/pkg/cache" + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/registry" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +var pullCommand = &cli.Command{ + Name: "pull", + Usage: "Verify and pull an image from a registry", + ArgsUsage: "", + Action: pullImage, +} + +func pullImage(ctx *cli.Context) error { + originalRef := ctx.Args().First() + ref, err := verifyRemoteImage(ctx.Context, originalRef) + if err != nil { + return err + } + + if err := runCommand("docker", "pull", ref); err != nil { + return err + } + return runCommand("docker", "tag", ref, originalRef) +} + +func verifyRemoteImage(ctx context.Context, ref string) (string, error) { + manifestRef, err := registry.ParseReference(ref) + if err != nil { + return "", err + } + + service, err := getVerificationService() + if err != nil { + return "", err + } + + manifestDesc, err := docker.GetManifestOCIDescriptor(ctx, manifestRef) + if err != nil { + return "", err + } + fmt.Printf("%s: digest: %v size: %v\n", manifestRef.ReferenceOrDefault(), manifestDesc.Digest, manifestDesc.Size) + + fmt.Println("Looking up for signatures") + sigDigests, err := downloadSignatures(ctx, ref, manifestDesc.Digest) + if err != nil { + return "", err + } + switch n := len(sigDigests); n { + case 0: + return "", errors.New("no signature found") + default: + fmt.Println("Found", n, "signatures") + } + + sigDigest, originRefs, err := verifySignatures(ctx, service, sigDigests, manifestDesc) + if err != nil { + return "", fmt.Errorf("none of the signatures are valid: %v", err) + } + fmt.Println("Found valid signature:", sigDigest) + if len(originRefs) != 0 { + fmt.Println("The image is originated from:") + for _, origin := range originRefs { + fmt.Println("-", origin) + } + } + + manifestRef.Reference = manifestDesc.Digest.String() + return manifestRef.String(), nil +} + +func downloadSignatures(ctx context.Context, ref string, manifestDigest digest.Digest) ([]digest.Digest, error) { + client, err := docker.GetSignatureRepository(ctx, ref) + if err != nil { + return nil, err + } + sigDigests, err := client.Lookup(ctx, manifestDigest) + if err != nil { + return nil, err + } + + for _, sigDigest := range sigDigests { + if err := cache.PullSignature(ctx, client, manifestDigest, sigDigest); err != nil { + return nil, err + } + } + + return sigDigests, nil +} + +func verifySignatures( + ctx context.Context, + service notation.SigningService, + sigDigests []digest.Digest, + desc ocispec.Descriptor, +) (digest.Digest, []string, error) { + var lastError error + for _, sigDigest := range sigDigests { + path := config.SignaturePath(desc.Digest, sigDigest) + sig, err := os.ReadFile(path) + if err != nil { + return "", nil, err + } + + references, err := service.Verify(ctx, desc, sig) + if err != nil { + lastError = err + continue + } + return sigDigest, references, nil + } + return "", nil, lastError +} diff --git a/cmd/docker-notation/push.go b/cmd/docker-notation/push.go new file mode 100644 index 000000000..c324995ed --- /dev/null +++ b/cmd/docker-notation/push.go @@ -0,0 +1,118 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/distribution/distribution/v3/manifest/schema2" + "github.com/notaryproject/notation/cmd/docker-notation/docker" + "github.com/notaryproject/notation/pkg/cache" + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +var pushCommand = &cli.Command{ + Name: "push", + Usage: "Push an image to a registry with its signatures", + ArgsUsage: "", + Action: pushImage, +} + +func pushImage(ctx *cli.Context) error { + desc, err := pushImageAndGetOCIDescriptor(ctx) + if err != nil { + return err + } + + fmt.Println("Pushing signature") + sigDigests, err := cache.SignatureDigests(desc.Digest) + if err != nil { + return err + } + if len(sigDigests) == 0 { + return errors.New("no signatures found") + } + + client, err := docker.GetSignatureRepository(ctx.Context, ctx.Args().First()) + if err != nil { + return err + } + pushSignature := func(sigDigest digest.Digest) error { + sigPath := config.SignaturePath(desc.Digest, sigDigest) + sig, err := os.ReadFile(sigPath) + if err != nil { + return err + } + + sigDesc, err := client.Put(ctx.Context, sig) + if err != nil { + return err + } + + artifactDesc, err := client.Link(ctx.Context, desc, sigDesc) + if err != nil { + return err + } + fmt.Println("signature manifest digest:", artifactDesc.Digest, "size:", artifactDesc.Size) + return nil + } + for _, sigDigest := range sigDigests { + if err := pushSignature(sigDigest); err != nil { + return err + } + } + + return nil +} + +func pushImageAndGetOCIDescriptor(ctx *cli.Context) (ocispec.Descriptor, error) { + args := append([]string{"push"}, ctx.Args().Slice()...) + cmd := exec.Command("docker", args...) + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return ocispec.Descriptor{}, err + } + scanner := bufio.NewScanner(io.TeeReader(stdout, os.Stdout)) + if err := cmd.Start(); err != nil { + return ocispec.Descriptor{}, err + } + var lastLine string + for scanner.Scan() { + lastLine = scanner.Text() + } + if err := scanner.Err(); err != nil { + return ocispec.Descriptor{}, err + } + if err := cmd.Wait(); err != nil { + return ocispec.Descriptor{}, err + } + + parts := strings.Split(lastLine, " ") + if len(parts) != 5 { + return ocispec.Descriptor{}, fmt.Errorf("invalid docker pull result: %s", lastLine) + } + digest, err := digest.Parse(parts[2]) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("invalid digest: %s", lastLine) + } + size, err := strconv.ParseInt(parts[4], 10, 64) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("invalid size: %s", lastLine) + } + + return ocispec.Descriptor{ + MediaType: schema2.MediaTypeManifest, + Digest: digest, + Size: size, + }, nil +} diff --git a/cmd/docker-notation/sign.go b/cmd/docker-notation/sign.go new file mode 100644 index 000000000..6733b4577 --- /dev/null +++ b/cmd/docker-notation/sign.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + + "github.com/notaryproject/notation-go-lib" + "github.com/notaryproject/notation/cmd/docker-notation/crypto" + "github.com/notaryproject/notation/cmd/docker-notation/docker" + ios "github.com/notaryproject/notation/internal/os" + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" + "github.com/urfave/cli/v2" +) + +var signCommand = &cli.Command{ + Name: "sign", + Usage: "Sign a image", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "signing key name", + }, + &cli.PathFlag{ + Name: "key-file", + Usage: "signing key file", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "cert", + Aliases: []string{"c"}, + Usage: "signing certificate name", + }, + &cli.StringFlag{ + Name: "cert-file", + Usage: "signing certificate file", + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: "reference", + Aliases: []string{"r"}, + Usage: "original references", + }, + &cli.BoolFlag{ + Name: "origin", + Usage: "mark the current reference as a original reference", + }, + }, + Action: signImage, +} + +func signImage(ctx *cli.Context) error { + service, err := getSigningService(ctx) + if err != nil { + return err + } + + reference := ctx.Args().First() + fmt.Println("Generating Docker mainfest:", reference) + desc, err := docker.GenerateManifestOCIDescriptor(reference) + if err != nil { + return err + } + + fmt.Println("Signing", desc.Digest) + var references []string + if ctx.Bool("origin") { + references = append(references, reference) + } + references = append(references, ctx.StringSlice("reference")...) + sig, err := service.Sign(ctx.Context, desc, references...) + if err != nil { + return err + } + sigPath := config.SignaturePath(desc.Digest, digest.FromBytes(sig)) + if err := ios.WriteFile(sigPath, sig); err != nil { + return err + } + fmt.Println("Signature saved to", sigPath) + + return nil +} + +func getSigningService(ctx *cli.Context) (notation.SigningService, error) { + keyPath := ctx.String("key-file") + if keyPath == "" { + path, err := config.ResolveKeyPath(ctx.String("key")) + if err != nil { + return nil, err + } + keyPath = path + } + + var certPaths []string + if path := ctx.String("cert-file"); path != "" { + certPaths = []string{path} + } else if name := ctx.String("cert"); name != "" { + path, err := config.ResolveCertificatePath(name) + if err != nil { + return nil, err + } + certPaths = []string{path} + } + + return crypto.GetSigningService(keyPath, certPaths...) +} diff --git a/cmd/docker-notation/util.go b/cmd/docker-notation/util.go new file mode 100644 index 000000000..3aaade892 --- /dev/null +++ b/cmd/docker-notation/util.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "os/exec" + + "github.com/notaryproject/notation-go-lib" + "github.com/notaryproject/notation/cmd/docker-notation/crypto" + "github.com/notaryproject/notation/pkg/config" +) + +func runCommand(command string, args ...string) error { + cmd := exec.Command(command, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if err, ok := err.(*exec.ExitError); ok { + os.Exit(err.ExitCode()) + } + return err + } + return nil +} + +func getVerificationService() (notation.SigningService, error) { + cfg, err := config.LoadOrDefaultOnce() + if err != nil { + return nil, err + } + var certPaths []string + for _, cert := range cfg.VerificationCertificates.Certificates { + certPaths = append(certPaths, cert.Path) + } + return crypto.GetSigningService("", certPaths...) +} diff --git a/cmd/notation/cache.go b/cmd/notation/cache.go new file mode 100644 index 000000000..516801828 --- /dev/null +++ b/cmd/notation/cache.go @@ -0,0 +1,268 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/registry" + "github.com/opencontainers/go-digest" + "github.com/urfave/cli/v2" +) + +var ( + cacheCommand = &cli.Command{ + Name: "cache", + Usage: "Manage signature cache", + Subcommands: []*cli.Command{ + cacheListCommand, + cachePruneCommand, + cacheRemoveCommand, + }, + } + + cacheListCommand = &cli.Command{ + Name: "list", + Usage: "List signatures in cache", + Aliases: []string{"ls"}, + Flags: []cli.Flag{ + localFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + ArgsUsage: "[reference|manifest_digest]", + Action: listCachedSignatures, + } + + cachePruneCommand = &cli.Command{ + Name: "prune", + Usage: "Prune signature from cache", + ArgsUsage: "[reference|manifest_digest] ...", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "all", + Aliases: []string{"a"}, + Usage: "prune all cached signatures", + }, + &cli.BoolFlag{ + Name: "purge", + Usage: "remove the signature directory, combined with --all", + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "do not prompt for confirmation", + }, + localFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + Action: pruneCachedSignatures, + } + + cacheRemoveCommand = &cli.Command{ + Name: "remove", + Usage: "Remove signature from cache", + Aliases: []string{"rm"}, + ArgsUsage: " ...", + Flags: []cli.Flag{ + localFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + Action: removeCachedSignatures, + } +) + +func listCachedSignatures(ctx *cli.Context) error { + if !ctx.Args().Present() { + return listManifestsWithCachedSignature() + } + + manifestDigest, err := getManifestDigestFromContext(ctx, ctx.Args().First()) + if err != nil { + return err + } + + fmt.Println("SIGNATURE") + return walkCachedSignatureTree( + config.SignatureRootPath(manifestDigest), + func(algorithm string, value fs.DirEntry) error { + if strings.HasSuffix(value.Name(), config.SignatureExtension) { + encoded := strings.TrimSuffix(value.Name(), config.SignatureExtension) + fmt.Printf("%s:%s\n", algorithm, encoded) + } + return nil + }) +} + +func listManifestsWithCachedSignature() error { + fmt.Println("MANIFEST") + return walkCachedSignatureTree( + config.SignatureStoreDirPath, + func(algorithm string, value fs.DirEntry) error { + if value.IsDir() { + fmt.Printf("%s:%s\n", algorithm, value.Name()) + } + return nil + }) +} + +func pruneCachedSignatures(ctx *cli.Context) error { + if ctx.Bool("all") { + if !ctx.Bool("force") { + fmt.Println("WARNING! This will remove:") + fmt.Println("- all cached signatures") + if ctx.Bool("purge") { + fmt.Println("- all files in the cache signature directory") + } + fmt.Println() + if confirmed := promptConfirmation(); !confirmed { + return nil + } + } + if err := walkCachedSignatureTree( + config.SignatureStoreDirPath, + func(algorithm string, value fs.DirEntry) error { + if !value.IsDir() { + return nil + } + manifestDigest := digest.NewDigestFromEncoded(digest.Algorithm(algorithm), value.Name()) + if err := os.RemoveAll(config.SignatureRootPath(manifestDigest)); err != nil { + return err + } + + // write out + fmt.Println(manifestDigest) + return nil + }, + ); err != nil { + return err + } + if ctx.Bool("purge") { + return os.RemoveAll(config.SignatureStoreDirPath) + } + return nil + } + + if !ctx.Args().Present() { + return errors.New("nothing to prune") + } + refs := ctx.Args().Slice() + if !ctx.Bool("force") { + fmt.Println("WARNING! This will remove cached signatures for manifests below:") + for _, ref := range refs { + fmt.Println("-", ref) + } + fmt.Println() + if confirmed := promptConfirmation(); !confirmed { + return nil + } + } + for _, ref := range refs { + manifestDigest, err := getManifestDigestFromContext(ctx, ref) + if err != nil { + return err + } + if err := os.RemoveAll(config.SignatureRootPath(manifestDigest)); err != nil { + return err + } + + // write out + fmt.Println(manifestDigest) + + } + return nil +} + +func removeCachedSignatures(ctx *cli.Context) error { + // initialize + sigDigests := ctx.Args().Slice() + if len(sigDigests) == 0 { + return errors.New("missing target manifest") + } + sigDigests = sigDigests[1:] + if len(sigDigests) == 0 { + return errors.New("no signature specified") + } + + manifestDigest, err := getManifestDigestFromContext(ctx, ctx.Args().First()) + if err != nil { + return err + } + + // core process + for _, sigDigest := range sigDigests { + path := config.SignaturePath(manifestDigest, digest.Digest(sigDigest)) + if err := os.Remove(path); err != nil { + return err + } + + // write out + fmt.Println(sigDigest) + } + + return nil +} + +func walkCachedSignatureTree(root string, fn func(algorithm string, encodedEntry fs.DirEntry) error) error { + algorithms, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, alg := range algorithms { + if !alg.IsDir() { + continue + } + encodedEntries, err := os.ReadDir(filepath.Join(root, alg.Name())) + if err != nil { + return err + } + for _, encodedEntry := range encodedEntries { + if err := fn(alg.Name(), encodedEntry); err != nil { + return err + } + } + } + return nil +} + +func getManifestDigestFromContext(ctx *cli.Context, ref string) (manifestDigest digest.Digest, err error) { + manifestDigest, err = digest.Parse(ref) + if err == nil { + return + } + + reference, err := registry.ParseReference(ref) + if err != nil { + return + } + manifestDigest, err = reference.Digest() + if err == nil { + return + } + + manifest, err := getManifestDescriptorFromContextWithReference(ctx, ref) + if err != nil { + return + } + manifestDigest = digest.Digest(manifest.Digest) + return +} + +func promptConfirmation() bool { + fmt.Printf("Are you sure you want to continue? [y/N]: ") + scanner := bufio.NewScanner(os.Stdin) + return scanner.Scan() && strings.EqualFold(scanner.Text(), "y") +} diff --git a/cmd/notation/cert.go b/cmd/notation/cert.go new file mode 100644 index 000000000..fbcedd892 --- /dev/null +++ b/cmd/notation/cert.go @@ -0,0 +1,196 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "time" + + "github.com/notaryproject/notation-go-lib/signature/x509" + "github.com/notaryproject/notation/pkg/config" + "github.com/urfave/cli/v2" +) + +var ( + certCommand = &cli.Command{ + Name: "certificate", + Aliases: []string{"cert"}, + Usage: "Manage certificates used for verification", + Subcommands: []*cli.Command{ + certAddCommand, + certListCommand, + certRemoveCommand, + certGenerateTestCommand, + }, + } + + certAddCommand = &cli.Command{ + Name: "add", + Usage: "Add certificate to verification list", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "certificate name", + }, + }, + Action: addCert, + } + + certListCommand = &cli.Command{ + Name: "list", + Usage: "List certificates used for verification", + Aliases: []string{"ls"}, + Action: listCerts, + } + + certRemoveCommand = &cli.Command{ + Name: "remove", + Usage: "Remove certificate from the verification list", + Aliases: []string{"rm"}, + ArgsUsage: " ...", + Action: removeCerts, + } + + certGenerateTestCommand = &cli.Command{ + Name: "generate-test", + Usage: "Generates a test RSA key and a corresponding self-signed certificate", + ArgsUsage: " ...", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "key and certificate name", + }, + &cli.IntFlag{ + Name: "bits", + Usage: "RSA key bits", + Aliases: []string{"b"}, + Value: 2048, + }, + &cli.DurationFlag{ + Name: "expiry", + Aliases: []string{"e"}, + Usage: "certificate expiry", + Value: 365 * 24 * time.Hour, + }, + &cli.BoolFlag{ + Name: "trust", + Usage: "add the generated certificate to the verification list", + }, + keyDefaultFlag, + }, + Action: generateTestCert, + } +) + +func addCert(ctx *cli.Context) error { + // initialize + path := ctx.Args().First() + if path == "" { + return errors.New("missing certificate path") + } + path, err := filepath.Abs(path) + if err != nil { + return err + } + name := ctx.String("name") + if name == "" { + name = nameFromPath(path) + } + + // check if the target path is a cert + if _, err := x509.ReadCertificateFile(path); err != nil { + return err + } + + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + if err := addCertCore(cfg, name, path); err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + + // write out + fmt.Println(name) + return nil +} + +func addCertCore(cfg *config.File, name, path string) error { + if ok := cfg.VerificationCertificates.Certificates.Append(name, path); !ok { + return errors.New(name + ": already exists") + } + return nil +} + +func listCerts(ctx *cli.Context) error { + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + + // write out + printCertificateSet(cfg.VerificationCertificates.Certificates) + return nil +} + +func removeCerts(ctx *cli.Context) error { + // initialize + names := ctx.Args().Slice() + if len(names) == 0 { + return errors.New("missing certificate names") + } + + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + + var removedNames []string + for _, name := range names { + if ok := cfg.VerificationCertificates.Certificates.Remove(name); !ok { + return errors.New(name + ": not found") + } + removedNames = append(removedNames, name) + } + if err := cfg.Save(); err != nil { + return err + } + + // write out + for _, name := range removedNames { + fmt.Println(name) + } + return nil +} + +func printCertificateSet(s config.FileSet) { + maxNameSize := 0 + for _, ref := range s { + if len(ref.Name) > maxNameSize { + maxNameSize = len(ref.Name) + } + } + format := fmt.Sprintf("%%-%ds\t%%s\n", maxNameSize) + fmt.Printf(format, "NAME", "PATH") + for _, ref := range s { + fmt.Printf(format, ref.Name, ref.Path) + } +} + +func nameFromPath(path string) string { + base := filepath.Base(path) + name := base[:len(base)-len(filepath.Ext(base))] + if name == "" { + return base + } + return name +} diff --git a/cmd/notation/cert_gen.go b/cmd/notation/cert_gen.go new file mode 100644 index 000000000..a6f6c810e --- /dev/null +++ b/cmd/notation/cert_gen.go @@ -0,0 +1,139 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "time" + + "github.com/notaryproject/notation/internal/os" + "github.com/notaryproject/notation/pkg/config" + "github.com/urfave/cli/v2" +) + +func generateTestCert(ctx *cli.Context) error { + // initialize + hosts := ctx.Args().Slice() + if len(hosts) == 0 { + return errors.New("missing certificate hosts") + } + name := ctx.String("name") + if name == "" { + name = hosts[0] + } + + // generate RSA private key + bits := ctx.Int("bits") + fmt.Println("generating RSA Key with", bits, "bits") + key, keyBytes, err := generateTestKey(bits) + if err != nil { + return err + } + + // generate self-signed certificate + cert, certBytes, err := generateTestSelfSignedCert(key, hosts, ctx.Duration("expiry")) + if err != nil { + return err + } + fmt.Println("generated certificates expiring on", cert.NotAfter.Format(time.RFC3339)) + + // write private key + keyPath := config.KeyPath(name) + if err := os.WriteFileWithPermission(keyPath, keyBytes, 0600, false); err != nil { + return fmt.Errorf("failed to write key file: %v", err) + } + fmt.Println("wrote key:", keyPath) + + // write self-signed certificate + certPath := config.CertificatePath(name) + if err := os.WriteFileWithPermission(certPath, certBytes, 0644, false); err != nil { + return fmt.Errorf("failed to write certificate file: %v", err) + } + fmt.Println("wrote certificate:", certPath) + + // update config + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + isDefaultKey, err := addKeyCore(cfg, name, keyPath, ctx.Bool(keyDefaultFlag.Name)) + if err != nil { + return err + } + trust := ctx.Bool("trust") + if trust { + if err := addCertCore(cfg, name, certPath); err != nil { + return err + } + } + if err := cfg.Save(); err != nil { + return err + } + + // write out + fmt.Printf("%s: added to the key list\n", name) + if isDefaultKey { + fmt.Printf("%s: marked as default\n", name) + } + if trust { + fmt.Printf("%s: added to the certificate list\n", name) + } + return nil +} + +func generateTestKey(bits int) (crypto.Signer, []byte, error) { + key, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, nil, err + } + keyBytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, nil, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) + return key, keyPEM, nil +} + +func generateTestSelfSignedCert(key crypto.Signer, hosts []string, expiry time.Duration) (*x509.Certificate, []byte, error) { + now := time.Now() + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %v", err) + } + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: hosts[0], + }, + NotBefore: now, + NotAfter: now.Add(expiry), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, + BasicConstraintsValid: true, + } + for _, host := range hosts { + if ip := net.ParseIP(host); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, host) + } + } + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, key.Public(), key) + if err != nil { + return nil, nil, fmt.Errorf("failed to create certificate: %v", err) + } + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, nil, fmt.Errorf("generated invalid certificate: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + return cert, certPEM, nil +} diff --git a/cmd/notation/common.go b/cmd/notation/common.go new file mode 100644 index 000000000..bd1d552f3 --- /dev/null +++ b/cmd/notation/common.go @@ -0,0 +1,43 @@ +package main + +import "github.com/urfave/cli/v2" + +var ( + usernameFlag = &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "username for generic remote access", + EnvVars: []string{"NOTATION_USERNAME"}, + } + passwordFlag = &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "password for generic remote access", + EnvVars: []string{"NOTATION_PASSWORD"}, + } + plainHTTPFlag = &cli.BoolFlag{ + Name: "plain-http", + Usage: "remote access via plain HTTP", + } + mediaTypeFlag = &cli.StringFlag{ + Name: "media-type", + Usage: "specify the media type of the manifest read from file or stdin", + Value: "application/vnd.docker.distribution.manifest.v2+json", + } + outputFlag = &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "write signature to a specific path", + } + localFlag = &cli.BoolFlag{ + Name: "local", + Aliases: []string{"l"}, + Usage: "reference is a local file", + } + signatureFlag = &cli.StringSliceFlag{ + Name: "signature", + Aliases: []string{"s", "f"}, + Usage: "signature files", + TakesFile: true, + } +) diff --git a/cmd/notation/key.go b/cmd/notation/key.go new file mode 100644 index 000000000..9ea969313 --- /dev/null +++ b/cmd/notation/key.go @@ -0,0 +1,227 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/notaryproject/notation-go-lib/signature/x509" + "github.com/notaryproject/notation/pkg/config" + "github.com/urfave/cli/v2" +) + +var ( + keyCommand = &cli.Command{ + Name: "key", + Usage: "Manage keys used for signing", + Subcommands: []*cli.Command{ + keyAddCommand, + keyUpdateCommand, + keyListCommand, + keyRemoveCommand, + }, + } + + keyDefaultFlag = &cli.BoolFlag{ + Name: "default", + Aliases: []string{"d"}, + Usage: "mark as default", + } + + keyAddCommand = &cli.Command{ + Name: "add", + Usage: "Add key to signing key list", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "key name", + }, + keyDefaultFlag, + }, + Action: addKey, + } + + keyUpdateCommand = &cli.Command{ + Name: "update", + Usage: "Update key in signing key list", + Aliases: []string{"set"}, + ArgsUsage: "", + Flags: []cli.Flag{ + keyDefaultFlag, + }, + Action: updateKey, + } + + keyListCommand = &cli.Command{ + Name: "list", + Usage: "List keys used for signing", + Aliases: []string{"ls"}, + Action: listKeys, + } + + keyRemoveCommand = &cli.Command{ + Name: "remove", + Usage: "Remove key from signing key list", + Aliases: []string{"rm"}, + ArgsUsage: "[name] ...", + Action: removeKeys, + } +) + +func addKey(ctx *cli.Context) error { + // initialize + path := ctx.Args().First() + if path == "" { + return errors.New("missing key path") + } + path, err := filepath.Abs(path) + if err != nil { + return err + } + name := ctx.String("name") + if name == "" { + name = nameFromPath(path) + } + + // check if the target path is a key + if _, err := x509.ReadPrivateKeyFile(path); err != nil { + return err + } + + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + isDefault, err := addKeyCore(cfg, name, path, ctx.Bool(keyDefaultFlag.Name)) + if err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + + // write out + if isDefault { + fmt.Printf("%s: marked as default\n", name) + } else { + fmt.Println(name) + } + return nil +} + +func addKeyCore(cfg *config.File, name, path string, markDefault bool) (bool, error) { + if ok := cfg.SigningKeys.Keys.Append(name, path); !ok { + return false, errors.New(name + ": already exists") + } + if markDefault { + cfg.SigningKeys.Default = name + } + return cfg.SigningKeys.Default == name, nil +} + +func updateKey(ctx *cli.Context) error { + // initialize + name := ctx.Args().First() + if name == "" { + return errors.New("missing key name") + } + + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + if _, ok := cfg.SigningKeys.Keys.Get(name); !ok { + return errors.New(name + ": not found") + } + if !ctx.Bool(keyDefaultFlag.Name) { + return nil + } + if cfg.SigningKeys.Default != name { + cfg.SigningKeys.Default = name + if err := cfg.Save(); err != nil { + return err + } + } + + // write out + fmt.Printf("%s: marked as default\n", name) + return nil +} + +func listKeys(ctx *cli.Context) error { + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + + // write out + printKeySet(cfg.SigningKeys.Default, cfg.SigningKeys.Keys) + return nil +} + +func removeKeys(ctx *cli.Context) error { + // initialize + names := ctx.Args().Slice() + if len(names) == 0 { + return errors.New("missing key names") + } + + // core process + cfg, err := config.LoadOrDefault() + if err != nil { + return err + } + + prevDefault := cfg.SigningKeys.Default + var removedNames []string + for _, name := range names { + if ok := cfg.SigningKeys.Keys.Remove(name); !ok { + return errors.New(name + ": not found") + } + removedNames = append(removedNames, name) + if prevDefault == name { + cfg.SigningKeys.Default = "" + } + } + if err := cfg.Save(); err != nil { + return err + } + + // write out + for _, name := range removedNames { + if prevDefault == name { + fmt.Printf("%s: unmarked as default\n", name) + } else { + fmt.Println(name) + } + } + return nil +} + +func printKeySet(target string, s config.FileSet) { + if len(s) == 0 { + fmt.Println("NAME\tPATH") + return + } + + maxNameSize := 0 + for _, ref := range s { + if len(ref.Name) > maxNameSize { + maxNameSize = len(ref.Name) + } + } + format := fmt.Sprintf("%%c %%-%ds\t%%s\n", maxNameSize) + fmt.Printf(format, ' ', "NAME", "PATH") + for _, ref := range s { + mark := ' ' + if ref.Name == target { + mark = '*' + } + fmt.Printf(format, mark, ref.Name, ref.Path) + } +} diff --git a/cmd/notation/list.go b/cmd/notation/list.go new file mode 100644 index 000000000..7c519e728 --- /dev/null +++ b/cmd/notation/list.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/urfave/cli/v2" +) + +var listCommand = &cli.Command{ + Name: "list", + Usage: "List signatures from remote", + Aliases: []string{"ls"}, + ArgsUsage: "", + Flags: []cli.Flag{ + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + Action: runList, +} + +func runList(ctx *cli.Context) error { + // initialize + if !ctx.Args().Present() { + return errors.New("no reference specified") + } + + reference := ctx.Args().First() + sigRepo, err := getSignatureRepository(ctx, reference) + if err != nil { + return err + } + + // core process + manifestDesc, err := getManifestDescriptorFromReference(ctx, reference) + if err != nil { + return err + } + + sigDigests, err := sigRepo.Lookup(ctx.Context, manifestDesc.Digest) + if err != nil { + return fmt.Errorf("lookup signature failure: %v", err) + } + + // write out + for _, sigDigest := range sigDigests { + fmt.Println(sigDigest) + } + + return nil +} diff --git a/cmd/notation/main.go b/cmd/notation/main.go new file mode 100644 index 000000000..a51fa425d --- /dev/null +++ b/cmd/notation/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "os" + + "github.com/notaryproject/notation/internal/version" + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "notation", + Usage: "Notation - Notary V2", + Version: version.GetVersion(), + Authors: []*cli.Author{ + { + Name: "CNCF Notary Project", + }, + }, + Commands: []*cli.Command{ + signCommand, + verifyCommand, + pushCommand, + pullCommand, + listCommand, + certCommand, + keyCommand, + cacheCommand, + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/notation/manifest.go b/cmd/notation/manifest.go new file mode 100644 index 000000000..2486f0308 --- /dev/null +++ b/cmd/notation/manifest.go @@ -0,0 +1,79 @@ +package main + +import ( + "errors" + "io" + "math" + "os" + + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/registry" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +func getManifestDescriptorFromContext(ctx *cli.Context) (ocispec.Descriptor, error) { + ref := ctx.Args().First() + if ref == "" { + return ocispec.Descriptor{}, errors.New("missing reference") + } + return getManifestDescriptorFromContextWithReference(ctx, ref) +} + +func getManifestDescriptorFromContextWithReference(ctx *cli.Context, ref string) (ocispec.Descriptor, error) { + if ctx.Bool(localFlag.Name) { + mediaType := ctx.String(mediaTypeFlag.Name) + if ref == "-" { + return getManifestDescriptorFromReader(os.Stdin, mediaType) + } + return getManifestDescriptorFromFile(ref, mediaType) + } + + return getManifestDescriptorFromReference(ctx, ref) +} + +func getManifestDescriptorFromReference(ctx *cli.Context, reference string) (ocispec.Descriptor, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + return ocispec.Descriptor{}, err + } + plainHTTP := ctx.Bool(plainHTTPFlag.Name) + if !plainHTTP { + plainHTTP = config.IsRegistryInsecure(ref.Registry) + } + remote := registry.NewClient( + registry.NewAuthtransport( + nil, + ctx.String(usernameFlag.Name), + ctx.String(passwordFlag.Name), + ), + plainHTTP, + ) + return remote.GetManifestDescriptor(ref) +} + +func getManifestDescriptorFromFile(path, mediaType string) (ocispec.Descriptor, error) { + file, err := os.Open(path) + if err != nil { + return ocispec.Descriptor{}, err + } + defer file.Close() + return getManifestDescriptorFromReader(file, mediaType) +} + +func getManifestDescriptorFromReader(r io.Reader, mediaType string) (ocispec.Descriptor, error) { + lr := &io.LimitedReader{ + R: r, + N: math.MaxInt64, + } + digest, err := digest.SHA256.FromReader(lr) + if err != nil { + return ocispec.Descriptor{}, err + } + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: digest, + Size: math.MaxInt64 - lr.N, + }, nil +} diff --git a/cmd/notation/pull.go b/cmd/notation/pull.go new file mode 100644 index 000000000..2667683d9 --- /dev/null +++ b/cmd/notation/pull.go @@ -0,0 +1,127 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/notaryproject/notation-go-lib" + "github.com/notaryproject/notation/internal/os" + "github.com/notaryproject/notation/pkg/cache" + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/registry" + "github.com/opencontainers/go-digest" + "github.com/urfave/cli/v2" +) + +var pullCommand = &cli.Command{ + Name: "pull", + Usage: "Pull signatures from remote", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "strict", + Usage: "pull the signature without lookup the manifest", + }, + outputFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + Action: runPull, +} + +func runPull(ctx *cli.Context) error { + // initialize + if !ctx.Args().Present() { + return errors.New("no reference specified") + } + + reference := ctx.Args().First() + sigRepo, err := getSignatureRepository(ctx, reference) + if err != nil { + return err + } + + // core process + if ctx.Bool("strict") { + return pullSignatureStrict(ctx, sigRepo, reference) + } + + manifestDesc, err := getManifestDescriptorFromReference(ctx, reference) + if err != nil { + return err + } + + sigDigests, err := sigRepo.Lookup(ctx.Context, manifestDesc.Digest) + if err != nil { + return fmt.Errorf("lookup signature failure: %v", err) + } + + path := ctx.String(outputFlag.Name) + for _, sigDigest := range sigDigests { + if path != "" { + outputPath := filepath.Join(path, sigDigest.Encoded()+config.SignatureExtension) + sig, err := sigRepo.Get(ctx.Context, sigDigest) + if err != nil { + return fmt.Errorf("get signature failure: %v: %v", sigDigest, err) + } + if err := os.WriteFile(outputPath, sig); err != nil { + return fmt.Errorf("fail to write signature: %v: %v", sigDigest, err) + } + } else if err := cache.PullSignature(ctx.Context, sigRepo, manifestDesc.Digest, sigDigest); err != nil { + return err + } + + // write out + fmt.Println(sigDigest) + } + + return nil +} + +func pullSignatureStrict(ctx *cli.Context, sigRepo notation.SignatureRepository, reference string) error { + ref, err := registry.ParseReference(reference) + if err != nil { + return err + } + sigDigest, err := ref.Digest() + if err != nil { + return fmt.Errorf("invalid signature digest: %v", err) + } + + sig, err := sigRepo.Get(ctx.Context, sigDigest) + if err != nil { + return fmt.Errorf("get signature failure: %v: %v", sigDigest, err) + } + outputPath := ctx.String(outputFlag.Name) + if outputPath == "" { + outputPath = sigDigest.Encoded() + config.SignatureExtension + } + if err := os.WriteFile(outputPath, sig); err != nil { + return fmt.Errorf("fail to write signature: %v: %v", sigDigest, err) + } + + // write out + fmt.Println(sigDigest) + return nil +} + +func pullSignatures(ctx *cli.Context, manifestDigest digest.Digest) error { + reference := ctx.Args().First() + sigRepo, err := getSignatureRepository(ctx, reference) + if err != nil { + return err + } + + sigDigests, err := sigRepo.Lookup(ctx.Context, manifestDigest) + if err != nil { + return fmt.Errorf("lookup signature failure: %v", err) + } + for _, sigDigest := range sigDigests { + if err := cache.PullSignature(ctx.Context, sigRepo, manifestDigest, sigDigest); err != nil { + return err + } + } + return nil +} diff --git a/cmd/notation/push.go b/cmd/notation/push.go new file mode 100644 index 000000000..af8ea594f --- /dev/null +++ b/cmd/notation/push.go @@ -0,0 +1,96 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/notaryproject/notation/pkg/cache" + "github.com/notaryproject/notation/pkg/config" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +var pushCommand = &cli.Command{ + Name: "push", + Usage: "Push signature to remote", + ArgsUsage: "", + Flags: []cli.Flag{ + signatureFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + }, + Action: runPush, +} + +func runPush(ctx *cli.Context) error { + // initialize + if !ctx.Args().Present() { + return errors.New("no reference specified") + } + ref := ctx.Args().First() + manifestDesc, err := getManifestDescriptorFromReference(ctx, ref) + if err != nil { + return err + } + sigPaths := ctx.StringSlice(signatureFlag.Name) + if len(sigPaths) == 0 { + sigDigests, err := cache.SignatureDigests(manifestDesc.Digest) + if err != nil { + return err + } + for _, sigDigest := range sigDigests { + sigPaths = append(sigPaths, config.SignaturePath(manifestDesc.Digest, sigDigest)) + } + } + + // core process + sigRepo, err := getSignatureRepository(ctx, ref) + if err != nil { + return err + } + for _, path := range sigPaths { + sig, err := os.ReadFile(path) + if err != nil { + return err + } + sigDesc, err := sigRepo.Put(ctx.Context, sig) + if err != nil { + return fmt.Errorf("push signature failure: %v", err) + } + desc, err := sigRepo.Link(ctx.Context, manifestDesc, sigDesc) + if err != nil { + return fmt.Errorf("link signature failure: %v", err) + } + + // write out + fmt.Println(desc.Digest) + } + + return nil +} + +func pushSignature(ctx *cli.Context, ref string, sig []byte) (ocispec.Descriptor, error) { + // initialize + sigRepo, err := getSignatureRepository(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, err + } + manifestDesc, err := getManifestDescriptorFromReference(ctx, ref) + if err != nil { + return ocispec.Descriptor{}, err + } + + // core process + sigDesc, err := sigRepo.Put(ctx.Context, sig) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("push signature failure: %v", err) + } + desc, err := sigRepo.Link(ctx.Context, manifestDesc, sigDesc) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("link signature failure: %v", err) + } + + return desc, nil +} diff --git a/cmd/notation/registry.go b/cmd/notation/registry.go new file mode 100644 index 000000000..6d56d8b20 --- /dev/null +++ b/cmd/notation/registry.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/notaryproject/notation-go-lib" + registryn "github.com/notaryproject/notation-go-lib/registry" + "github.com/notaryproject/notation/pkg/config" + "github.com/notaryproject/notation/pkg/registry" + "github.com/urfave/cli/v2" +) + +func getSignatureRepository(ctx *cli.Context, reference string) (notation.SignatureRepository, error) { + ref, err := registry.ParseReference(reference) + if err != nil { + return nil, err + } + plainHTTP := ctx.Bool(plainHTTPFlag.Name) + if !plainHTTP { + plainHTTP = config.IsRegistryInsecure(ref.Registry) + } + remote := registryn.NewClient( + registry.NewAuthtransport( + nil, + ctx.String(usernameFlag.Name), + ctx.String(passwordFlag.Name), + ), + ref.Registry, + plainHTTP, + ) + return remote.Repository(ctx.Context, ref.Repository), nil +} diff --git a/cmd/notation/sign.go b/cmd/notation/sign.go new file mode 100644 index 000000000..12a752225 --- /dev/null +++ b/cmd/notation/sign.go @@ -0,0 +1,172 @@ +package main + +import ( + "fmt" + "time" + + "github.com/notaryproject/notation-go-lib/signature" + "github.com/notaryproject/notation-go-lib/signature/x509" + "github.com/notaryproject/notation/internal/os" + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +var signCommand = &cli.Command{ + Name: "sign", + Usage: "Signs artifacts", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "key", + Aliases: []string{"k"}, + Usage: "signing key name", + }, + &cli.StringFlag{ + Name: "key-file", + Usage: "signing key file", + TakesFile: true, + }, + &cli.StringFlag{ + Name: "cert", + Aliases: []string{"c"}, + Usage: "signing certificate name", + }, + &cli.StringFlag{ + Name: "cert-file", + Usage: "signing certificate file", + TakesFile: true, + }, + localFlag, + &cli.DurationFlag{ + Name: "expiry", + Aliases: []string{"e"}, + Usage: "expire duration", + }, + &cli.StringSliceFlag{ + Name: "reference", + Aliases: []string{"r"}, + Usage: "original references", + }, + outputFlag, + &cli.BoolFlag{ + Name: "push", + Usage: "push after successful signing", + Value: true, + }, + &cli.StringFlag{ + Name: "push-reference", + Usage: "different remote to store signature", + }, + usernameFlag, + passwordFlag, + plainHTTPFlag, + mediaTypeFlag, + }, + Action: runSign, +} + +func runSign(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForSigning(ctx) + if err != nil { + return err + } + + // core process + claims, err := prepareClaimsForSigning(ctx) + if err != nil { + return err + } + sig, err := scheme.Sign("", claims) + if err != nil { + return err + } + + // write out + path := ctx.String(outputFlag.Name) + if path == "" { + path = config.SignaturePath(digest.Digest(claims.Manifest.Digest), digest.FromString(sig)) + } + if err := os.WriteFile(path, []byte(sig)); err != nil { + return err + } + + if ctx.Bool("push") { + ref := ctx.String("push-reference") + if ref == "" { + ref = ctx.Args().First() + } + if _, err := pushSignature(ctx, ref, []byte(sig)); err != nil { + return fmt.Errorf("fail to push signature to %q: %v: %v", + ref, + claims.Manifest.Digest, + err, + ) + } + } + + fmt.Println(claims.Manifest.Digest) + return nil +} + +func prepareClaimsForSigning(ctx *cli.Context) (signature.Claims, error) { + manifestDesc, err := getManifestDescriptorFromContext(ctx) + if err != nil { + return signature.Claims{}, err + } + now := time.Now() + nowUnix := now.Unix() + claims := signature.Claims{ + Manifest: signature.Manifest{ + Descriptor: convertDescriptorToNotation(manifestDesc), + References: ctx.StringSlice("reference"), + }, + IssuedAt: nowUnix, + } + if expiry := ctx.Duration("expiry"); expiry != 0 { + claims.NotBefore = nowUnix + claims.Expiration = now.Add(expiry).Unix() + } + + return claims, nil +} + +func getSchemeForSigning(ctx *cli.Context) (*signature.Scheme, error) { + keyPath := ctx.String("key-file") + if keyPath == "" { + path, err := config.ResolveKeyPath(ctx.String("key")) + if err != nil { + return nil, err + } + keyPath = path + } + + certPath := ctx.String("cert-file") + if certPath == "" { + if name := ctx.String("cert"); name != "" { + path, err := config.ResolveCertificatePath(name) + if err != nil { + return nil, err + } + certPath = path + } + } + + signer, err := x509.NewSignerFromFiles(keyPath, certPath) + scheme := signature.NewScheme() + if err != nil { + return nil, err + } + scheme.RegisterSigner("", signer) + return scheme, nil +} + +func convertDescriptorToNotation(desc ocispec.Descriptor) signature.Descriptor { + return signature.Descriptor{ + MediaType: desc.MediaType, + Digest: desc.Digest.String(), + Size: desc.Size, + } +} diff --git a/cmd/notation/verify.go b/cmd/notation/verify.go new file mode 100644 index 000000000..4fd8c4367 --- /dev/null +++ b/cmd/notation/verify.go @@ -0,0 +1,198 @@ +package main + +import ( + "crypto/x509" + "errors" + "fmt" + "os" + + "github.com/notaryproject/notation-go-lib/signature" + x509n "github.com/notaryproject/notation-go-lib/signature/x509" + "github.com/notaryproject/notation/pkg/cache" + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" +) + +var verifyCommand = &cli.Command{ + Name: "verify", + Usage: "Verifies OCI Artifacts", + ArgsUsage: "", + Flags: []cli.Flag{ + signatureFlag, + &cli.StringSliceFlag{ + Name: "cert", + Aliases: []string{"c"}, + Usage: "certificate names for verification", + }, + &cli.StringSliceFlag{ + Name: "cert-file", + Usage: "certificate files for verification", + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: "ca-cert", + Usage: "CA certificate names for verification", + }, + &cli.StringSliceFlag{ + Name: "ca-cert-file", + Usage: "CA certificate files for verification", + TakesFile: true, + }, + &cli.BoolFlag{ + Name: "pull", + Usage: "pull remote signatures before verification", + Value: true, + }, + localFlag, + usernameFlag, + passwordFlag, + plainHTTPFlag, + mediaTypeFlag, + }, + Action: runVerify, +} + +func runVerify(ctx *cli.Context) error { + // initialize + scheme, err := getSchemeForVerification(ctx) + if err != nil { + return err + } + manifestDesc, err := getManifestDescriptorFromContext(ctx) + if err != nil { + return err + } + + sigPaths := ctx.StringSlice(signatureFlag.Name) + if len(sigPaths) == 0 { + if !ctx.Bool(localFlag.Name) && ctx.Bool("pull") { + if err := pullSignatures(ctx, digest.Digest(manifestDesc.Digest)); err != nil { + return err + } + } + manifestDigest := digest.Digest(manifestDesc.Digest) + sigDigests, err := cache.SignatureDigests(manifestDigest) + if err != nil { + return err + } + for _, sigDigest := range sigDigests { + sigPaths = append(sigPaths, config.SignaturePath(manifestDigest, sigDigest)) + } + } + + // core process + if err := verifySignatures(scheme, manifestDesc, sigPaths); err != nil { + return err + } + + // write out + fmt.Println(manifestDesc.Digest) + return nil +} + +func verifySignatures(scheme *signature.Scheme, manifestDesc ocispec.Descriptor, sigPaths []string) error { + if len(sigPaths) == 0 { + return errors.New("verification failure: no signatures found") + } + + var lastErr error + for _, path := range sigPaths { + sig, err := os.ReadFile(path) + if err != nil { + return err + } + claims, err := scheme.Verify(string(sig)) + if err != nil { + lastErr = fmt.Errorf("verification failure: %v", err) + continue + } + + if convertDescriptorToNotation(manifestDesc) != claims.Manifest.Descriptor { + lastErr = fmt.Errorf("verification failure: %s", manifestDesc.Digest) + continue + } + return nil + } + return lastErr +} + +func getSchemeForVerification(ctx *cli.Context) (*signature.Scheme, error) { + scheme := signature.NewScheme() + + // add x509 verifier + verifier, err := getX509Verifier(ctx) + if err != nil { + return nil, err + } + scheme.RegisterVerifier(verifier) + + return scheme, nil +} + +func getX509Verifier(ctx *cli.Context) (signature.Verifier, error) { + // resolve paths + certPaths := ctx.StringSlice("cert-file") + certPaths, err := appendCertPathFromName(certPaths, ctx.StringSlice("cert")) + if err != nil { + return nil, err + } + caCertPath := ctx.StringSlice("ca-cert-file") + caCertPath, err = appendCertPathFromName(caCertPath, ctx.StringSlice("ca-cert")) + if err != nil { + return nil, err + } + if len(certPaths) == 0 && len(caCertPath) == 0 { + cfg, err := config.LoadOrDefaultOnce() + if err != nil { + return nil, err + } + if len(cfg.VerificationCertificates.Certificates) == 0 { + return nil, errors.New("trust certificate not specified") + } + for _, ref := range cfg.VerificationCertificates.Certificates { + certPaths = append(certPaths, ref.Path) + } + } + + // read cert files + var certs []*x509.Certificate + roots := x509.NewCertPool() + for _, path := range certPaths { + bundledCerts, err := x509n.ReadCertificateFile(path) + if err != nil { + return nil, err + } + certs = append(certs, bundledCerts...) + for _, cert := range bundledCerts { + roots.AddCert(cert) + } + } + for _, path := range caCertPath { + bundledCerts, err := x509n.ReadCertificateFile(path) + if err != nil { + return nil, err + } + for _, cert := range bundledCerts { + roots.AddCert(cert) + } + } + + return x509n.NewVerifier(certs, roots) +} + +func appendCertPathFromName(paths, names []string) ([]string, error) { + for _, name := range names { + cfg, err := config.LoadOrDefaultOnce() + if err != nil { + return nil, err + } + path, ok := cfg.VerificationCertificates.Certificates.Get(name) + if !ok { + return nil, errors.New("verification certificate not found: " + name) + } + paths = append(paths, path) + } + return paths, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..9dbcb8fb1 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/notaryproject/notation + +go 1.17 + +require ( + github.com/distribution/distribution/v3 v3.0.0-20210804104954-38ab4c606ee3 + github.com/docker/cli v20.10.8+incompatible + github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 + github.com/notaryproject/notation-go-lib v0.0.0-20210915023405-484c01cab2ee + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.0.1 + github.com/oras-project/artifacts-spec v0.0.0-20210827194259-6e52c5a2ed3d + github.com/urfave/cli/v2 v2.3.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/docker/docker v20.10.8+incompatible // indirect + github.com/docker/docker-credential-helpers v0.6.4 // indirect + github.com/docker/go v1.5.1-1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sirupsen/logrus v1.8.1 // indirect + golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect + gotest.tools/v3 v3.0.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..762fcf941 --- /dev/null +++ b/go.sum @@ -0,0 +1,163 @@ +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= +github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/distribution/distribution/v3 v3.0.0-20210804104954-38ab4c606ee3 h1:rEK0juuU5idazw//KzUcL3yYwUU3DIe2OnfJwjDBqno= +github.com/distribution/distribution/v3 v3.0.0-20210804104954-38ab4c606ee3/go.mod h1:gt38b7cvVKazi5XkHvINNytZXgTEntyhtyM3HQz46Nk= +github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/docker/cli v20.10.8+incompatible h1:/zO/6y9IOpcehE49yMRTV9ea0nBpb8OeqSskXLNfH1E= +github.com/docker/cli v20.10.8+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v20.10.8+incompatible h1:RVqD337BgQicVCzYrrlhLDWhq6OAD2PJDUg2LsEUvKM= +github.com/docker/docker v20.10.8+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.6.4 h1:axCks+yV+2MR3/kZhAmy07yC56WZ2Pwu/fKWtKuZB0o= +github.com/docker/docker-credential-helpers v0.6.4/go.mod h1:ofX3UI0Gz1TteYBjtgs07O36Pyasyp66D2uKT7H8W1c= +github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k= +github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= +github.com/notaryproject/notation-go-lib v0.0.0-20210915023405-484c01cab2ee h1:idY+JlWPCkaXRIww/SuvqlaIWalN2o5o3jIcj2+mc9M= +github.com/notaryproject/notation-go-lib v0.0.0-20210915023405-484c01cab2ee/go.mod h1:KL5EBS/9X5UI7GnDC4NbYg2az6HVmoyH5M2Bv8a9E24= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/oras-project/artifacts-spec v0.0.0-20210827194259-6e52c5a2ed3d h1:fnJDGYyP6INkpdty1iJzXS3jI6n9RaLK0JN+glIPmsE= +github.com/oras-project/artifacts-spec v0.0.0-20210827194259-6e52c5a2ed3d/go.mod h1:Xch2aLzSwtkhbFFN6LUzTfLtukYvMMdXJ4oZ8O7BOdc= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= +google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/internal/docker/plugin.go b/internal/docker/plugin.go new file mode 100644 index 000000000..4c0dfec23 --- /dev/null +++ b/internal/docker/plugin.go @@ -0,0 +1,14 @@ +package docker + +// PluginMetadataCommandName is the internal command name for docker CLI plugin metadata. +const PluginMetadataCommandName = "docker-cli-plugin-metadata" + +// PluginMetadata presents the plugin metadata to the docker CLI. +type PluginMetadata struct { + SchemaVersion string `json:"SchemaVersion,omitempty"` + Vendor string `json:"Vendor,omitempty"` + Version string `json:"Version,omitempty"` + ShortDescription string `json:"ShortDescription,omitempty"` + URL string `json:"URL,omitempty"` + Experimental bool `json:"Experimental,omitempty"` +} diff --git a/internal/io/count.go b/internal/io/count.go new file mode 100644 index 000000000..52135dfce --- /dev/null +++ b/internal/io/count.go @@ -0,0 +1,22 @@ +package count + +import ( + "io" +) + +// CountWriter counts the written bytes +type CountWriter struct { + W io.Writer + N int64 +} + +// NewCountWriter generates a new writer +func NewCountWriter(w io.Writer) *CountWriter { + return &CountWriter{W: w} +} + +func (w *CountWriter) Write(p []byte) (n int, err error) { + n, err = w.W.Write(p) + w.N += int64(n) + return +} diff --git a/internal/os/file.go b/internal/os/file.go new file mode 100644 index 000000000..a239b0338 --- /dev/null +++ b/internal/os/file.go @@ -0,0 +1,38 @@ +package os + +import ( + "io/fs" + "os" + "path/filepath" +) + +// WriteFile writes to a path with all parent directories created. +func WriteFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + return os.WriteFile(path, data, 0666) +} + +// WriteFileWithPermission writes to a path with all parent directories created. +func WriteFileWithPermission(path string, data []byte, perm fs.FileMode, overwrite bool) error { + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + flag := os.O_WRONLY | os.O_CREATE + if overwrite { + flag |= os.O_TRUNC + } else { + flag |= os.O_EXCL + } + file, err := os.OpenFile(path, flag, perm) + if err != nil { + return err + } + _, err = file.Write(data) + if err != nil { + file.Close() + return err + } + return file.Close() +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 000000000..7d11d3173 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,17 @@ +package version + +var ( + // Version shows the current notation version, optionally with pre-release. + Version = "0.5.3" + + // BuildMetadata stores the build metadata. + BuildMetadata = "unreleased" +) + +// GetVersion returns the version string in SemVer 2. +func GetVersion() string { + if BuildMetadata == "" { + return Version + } + return Version + "+" + BuildMetadata +} diff --git a/pkg/cache/pull.go b/pkg/cache/pull.go new file mode 100644 index 000000000..d84485d04 --- /dev/null +++ b/pkg/cache/pull.go @@ -0,0 +1,36 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/notaryproject/notation-go-lib" + ios "github.com/notaryproject/notation/internal/os" + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" +) + +// PullSignature pulls the signature if not exists in the cache. +func PullSignature(ctx context.Context, sigRepo notation.SignatureRepository, manifestDigest, sigDigest digest.Digest) error { + sigPath := config.SignaturePath(manifestDigest, sigDigest) + if info, err := os.Stat(sigPath); err == nil { + if info.IsDir() { + return errors.New("found directory at the signature file path: " + sigPath) + } + return nil + } else if !os.IsNotExist(err) { + return err + } + + // fetch remote if not cached + sig, err := sigRepo.Get(ctx, sigDigest) + if err != nil { + return fmt.Errorf("get signature failure: %v: %v", sigDigest, err) + } + if err := ios.WriteFile(sigPath, sig); err != nil { + return fmt.Errorf("fail to write signature: %v: %v", sigDigest, err) + } + return nil +} diff --git a/pkg/cache/signature.go b/pkg/cache/signature.go new file mode 100644 index 000000000..513968728 --- /dev/null +++ b/pkg/cache/signature.go @@ -0,0 +1,52 @@ +package cache + +import ( + "os" + "path/filepath" + "strings" + + "github.com/notaryproject/notation/pkg/config" + "github.com/opencontainers/go-digest" +) + +// SignatureDigests returns the digest of signatures for a manifest +func SignatureDigests(manifestDigest digest.Digest) ([]digest.Digest, error) { + rootPath := config.SignatureRootPath(manifestDigest) + algorithmEntries, err := os.ReadDir(rootPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var digests []digest.Digest + for _, algorithmEntry := range algorithmEntries { + if !algorithmEntry.Type().IsDir() { + continue + } + + algorithm := algorithmEntry.Name() + signatureEntries, err := os.ReadDir(filepath.Join(rootPath, algorithm)) + if err != nil { + return nil, err + } + + for _, signatureEntry := range signatureEntries { + if !signatureEntry.Type().IsRegular() { + continue + } + encoded := signatureEntry.Name() + if !strings.HasSuffix(encoded, config.SignatureExtension) { + continue + } + encoded = strings.TrimSuffix(encoded, config.SignatureExtension) + digest := digest.NewDigestFromEncoded(digest.Algorithm(algorithm), encoded) + if err := digest.Validate(); err != nil { + return nil, err + } + digests = append(digests, digest) + } + } + return digests, nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 000000000..ba02b6f2b --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// File reflects the config file. +// Specification: https://github.com/notaryproject/notation/pull/76 +type File struct { + VerificationCertificates VerificationCertificates `json:"verificationCerts"` + SigningKeys SigningKeys `json:"signingKeys,omitempty"` + InsecureRegistries []string `json:"insecureRegistries"` +} + +// VerificationCertificates is a collection of public certs used for verification. +type VerificationCertificates struct { + Certificates FileSet `json:"certs"` +} + +// SigningKeys is a collection of signing keys. +type SigningKeys struct { + Default string `json:"default"` + Keys FileSet `json:"keys"` +} + +// New creates a new config file +func New() *File { + return &File{ + InsecureRegistries: []string{}, + } +} + +// Save stores the config to file +func (f *File) Save() error { + dir := filepath.Dir(FilePath) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + file, err := os.Create(FilePath) + if err != nil { + return err + } + defer file.Close() + encoder := json.NewEncoder(file) + encoder.SetIndent("", "\t") + return encoder.Encode(f) +} + +// Load reads the config from file +func Load() (*File, error) { + file, err := os.Open(FilePath) + if err != nil { + return nil, err + } + defer file.Close() + var config *File + if err := json.NewDecoder(file).Decode(&config); err != nil { + return nil, err + } + return config, nil +} + +// LoadOrDefault reads the config from file or return a default config if not found. +func LoadOrDefault() (*File, error) { + file, err := Load() + if err != nil { + if os.IsNotExist(err) { + return New(), nil + } + return nil, err + } + return file, nil +} diff --git a/pkg/config/once.go b/pkg/config/once.go new file mode 100644 index 000000000..7e4131850 --- /dev/null +++ b/pkg/config/once.go @@ -0,0 +1,20 @@ +package config + +import "sync" + +var ( + instance *File + instanceOnce sync.Once +) + +// LoadOrDefaultOnce returns the previously read config file. +// If previous config file does not exists, it reads the config from file +// or return a default config if not found. +// The returned config is only suitable for read only scenarios for short-lived processes. +func LoadOrDefaultOnce() (*File, error) { + var err error + instanceOnce.Do(func() { + instance, err = LoadOrDefault() + }) + return instance, err +} diff --git a/pkg/config/path.go b/pkg/config/path.go new file mode 100644 index 000000000..8926d1f0a --- /dev/null +++ b/pkg/config/path.go @@ -0,0 +1,97 @@ +package config + +import ( + "os" + "path/filepath" + + "github.com/opencontainers/go-digest" +) + +const ( + // ApplicationName is the name of the application + ApplicationName = "notation" + + // FileName is the name of config file + FileName = "config.json" + + // SignatureStoreDirName is the name of the signature store directory + SignatureStoreDirName = "signature" + + // SignatureExtension defines the extension of the signature files + SignatureExtension = ".sig" + + // KeyStoreDirName is the name of the key store directory + KeyStoreDirName = "key" + + // KeyExtension defines the extension of the key files + KeyExtension = ".key" + + // CertificateStoreDirName is the name of the certificate store directory + CertificateStoreDirName = "certificate" + + // CertificateExtension defines the extension of the certificate files + CertificateExtension = ".crt" +) + +var ( + // FilePath is the path of config file + FilePath string + + // SignatureStoreDirPath is the path of the signature store + SignatureStoreDirPath string + + // KeyStoreDirPath is the path of the key store + KeyStoreDirPath string + + // CertificateStoreDirPath is the path of the certificate store + CertificateStoreDirPath string +) + +// init initialize the essential file paths +func init() { + // init home directories + configDir, err := os.UserConfigDir() + if err != nil { + panic(err) + } + configDir = filepath.Join(configDir, ApplicationName) + cacheDir, err := os.UserCacheDir() + if err != nil { + panic(err) + } + cacheDir = filepath.Join(cacheDir, ApplicationName) + + // init paths + FilePath = filepath.Join(configDir, FileName) + SignatureStoreDirPath = filepath.Join(cacheDir, SignatureStoreDirName) + KeyStoreDirPath = filepath.Join(configDir, KeyStoreDirName) + CertificateStoreDirPath = filepath.Join(configDir, CertificateStoreDirName) +} + +// SignatureRootPath returns the root path of signatures for a manifest +func SignatureRootPath(manifestDigest digest.Digest) string { + return filepath.Join( + SignatureStoreDirPath, + manifestDigest.Algorithm().String(), + manifestDigest.Encoded(), + ) +} + +// SignaturePath returns the path of a signature for a manifest +func SignaturePath(manifestDigest, signatureDigest digest.Digest) string { + return filepath.Join( + SignatureRootPath(manifestDigest), + signatureDigest.Algorithm().String(), + signatureDigest.Encoded()+SignatureExtension, + ) +} + +// KeyPath returns the path of a signing key +func KeyPath(name string) string { + return filepath.Join(KeyStoreDirPath, name+KeyExtension) +} + +// CertificatePath returns the path of a certificate for verification +func CertificatePath(name string) string { + return filepath.Join(CertificateStoreDirPath, name+CertificateExtension) +} diff --git a/pkg/config/set.go b/pkg/config/set.go new file mode 100644 index 000000000..f9410d4d8 --- /dev/null +++ b/pkg/config/set.go @@ -0,0 +1,48 @@ +package config + +// FileReference is a named file path. +type FileReference struct { + Name string `json:"name"` + Path string `json:"path"` +} + +// FileSet is a set of FileReference indexed by name. +type FileSet []FileReference + +// Append appends a uniquely named path to the set. +// Return true if new values are appended. +func (s *FileSet) Append(name, path string) bool { + for _, ref := range *s { + if ref.Name == name { + return false + } + } + *s = append(*s, FileReference{ + Name: name, + Path: path, + }) + return true +} + +// Remove removes a named path from the set. +// Return true if an entry is found and removed. +func (s *FileSet) Remove(name string) bool { + for i, ref := range *s { + if ref.Name == name { + *s = append((*s)[:i], (*s)[i+1:]...) + return true + } + } + return false +} + +// Get return the path of the given name. +// Return true if found. +func (s FileSet) Get(name string) (string, bool) { + for _, ref := range s { + if ref.Name == name { + return ref.Path, true + } + } + return "", false +} diff --git a/pkg/config/util.go b/pkg/config/util.go new file mode 100644 index 000000000..336744026 --- /dev/null +++ b/pkg/config/util.go @@ -0,0 +1,58 @@ +package config + +import ( + "errors" + "strings" +) + +var ( + // ErrKeyNotFound indicates that the signing key is not found. + ErrKeyNotFound = errors.New("signing key not found") + + // ErrCertificateNotFound indicates that the verification certificate is not found. + ErrCertificateNotFound = errors.New("verification certificate not found") +) + +// IsRegistryInsecure checks whether the registry is in the list of insecure registries. +func IsRegistryInsecure(target string) bool { + config, err := LoadOrDefaultOnce() + if err != nil { + return false + } + for _, registry := range config.InsecureRegistries { + if strings.EqualFold(registry, target) { + return true + } + } + return false +} + +// ResolveKeyPath resolves the key path by name. +// The default key is attempted if name is empty. +func ResolveKeyPath(name string) (string, error) { + config, err := LoadOrDefaultOnce() + if err != nil { + return "", err + } + if name == "" { + name = config.SigningKeys.Default + } + path, ok := config.SigningKeys.Keys.Get(name) + if !ok { + return "", ErrKeyNotFound + } + return path, nil +} + +// ResolveCertificatePath resolves the certificate path by name. +func ResolveCertificatePath(name string) (string, error) { + config, err := LoadOrDefaultOnce() + if err != nil { + return "", err + } + path, ok := config.VerificationCertificates.Certificates.Get(name) + if !ok { + return "", ErrCertificateNotFound + } + return path, nil +} diff --git a/pkg/docker/schema2.go b/pkg/docker/schema2.go new file mode 100644 index 000000000..6a760cb40 --- /dev/null +++ b/pkg/docker/schema2.go @@ -0,0 +1,101 @@ +package docker + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/distribution/distribution/v3" + "github.com/distribution/distribution/v3/manifest/schema2" + iio "github.com/notaryproject/notation/internal/io" + "github.com/opencontainers/go-digest" +) + +type manifestInTar struct { + Config string + RepoTags []string + Layers []string +} + +// GenerateSchema2FromDockerSave generate a docker schema2 manifest from `docker save` +func GenerateSchema2FromDockerSave(reader io.Reader) (distribution.Manifest, error) { + items, descriptors, err := extractTar(reader) + if err != nil { + return nil, err + } + if len(items) != 1 { + return nil, errors.New("unsupported number of images") + } + item := items[0] + + layers := make([]distribution.Descriptor, 0, len(item.Layers)) + for _, layer := range item.Layers { + layers = append(layers, descriptors[layer]) + } + + manifest := schema2.Manifest{ + Versioned: schema2.SchemaVersion, + Config: descriptors[item.Config], + Layers: layers, + } + return schema2.FromStruct(manifest) +} + +func extractTar(r io.Reader) ([]manifestInTar, map[string]distribution.Descriptor, error) { + var manifests []manifestInTar + descriptors := make(map[string]distribution.Descriptor) + + tr := tar.NewReader(r) + for { + file, err := tr.Next() + if err != nil { + if err == io.EOF { + break + } + return nil, nil, err + } + switch { + case file.Name == "manifest.json": + decoder := json.NewDecoder(tr) + if err := decoder.Decode(&manifests); err != nil { + return nil, nil, err + } + case strings.HasSuffix(file.Name, "/layer.tar"): + desc, err := generateLayerDescriptor(tr) + if err != nil { + return nil, nil, err + } + descriptors[file.Name] = desc + case strings.HasSuffix(file.Name, ".json"): + digest := digest.NewDigestFromEncoded(digest.SHA256, strings.TrimSuffix(file.Name, ".json")) + descriptors[file.Name] = distribution.Descriptor{ + MediaType: schema2.MediaTypeImageConfig, + Size: file.Size, + Digest: digest, + } + } + } + + return manifests, descriptors, nil +} + +func generateLayerDescriptor(r io.Reader) (distribution.Descriptor, error) { + digester := digest.SHA256.Digester() + count := iio.NewCountWriter(digester.Hash()) + w := gzip.NewWriter(count) + _, err := io.Copy(w, r) + if err != nil { + return distribution.Descriptor{}, err + } + if err := w.Close(); err != nil { + return distribution.Descriptor{}, err + } + return distribution.Descriptor{ + MediaType: schema2.MediaTypeLayer, + Size: count.N, + Digest: digester.Digest(), + }, nil +} diff --git a/pkg/registry/auth.go b/pkg/registry/auth.go new file mode 100644 index 000000000..43b44004d --- /dev/null +++ b/pkg/registry/auth.go @@ -0,0 +1,115 @@ +package registry + +import ( + "encoding/json" + "net/http" + "net/url" + "regexp" + "strings" +) + +var authHeaderRegex = regexp.MustCompile(`(realm|service|scope)="([^"]*)`) + +type authTransport struct { + base http.RoundTripper + username string + password string +} + +// NewAuthtransport creates wraps a round tripper with auth strategies. +// It tries basic auth first and then falls back to token auth. +func NewAuthtransport(base http.RoundTripper, username, password string) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &authTransport{ + base: base, + username: username, + password: password, + } +} + +func (t *authTransport) RoundTrip(originalReq *http.Request) (*http.Response, error) { + req := originalReq.Clone(originalReq.Context()) + if t.username != "" { + req.SetBasicAuth(t.username, t.password) + } + + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + + scheme, params := parseAuthHeader(resp.Header.Get("Www-Authenticate")) + if scheme != "bearer" { + return resp, nil + } + resp.Body.Close() + + token, resp, err := t.fetchToken(params) + if err != nil { + if resp != nil { + return resp, nil + } + return nil, err + } + + req = originalReq.Clone(originalReq.Context()) + req.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(req) +} + +func (t *authTransport) fetchToken(params map[string]string) (string, *http.Response, error) { + req, err := http.NewRequest(http.MethodGet, params["realm"], nil) + if err != nil { + return "", nil, err + } + if t.username != "" { + req.SetBasicAuth(t.username, t.password) + } + + query := url.Values{} + if service, ok := params["service"]; ok { + query.Set("service", service) + } + if scope, ok := params["scope"]; ok { + query.Set("scope", scope) + } + req.URL.RawQuery = query.Encode() + + resp, err := t.base.RoundTrip(req) + if err != nil { + return "", nil, err + } + if resp.StatusCode != http.StatusOK { + return "", resp, nil + } + defer resp.Body.Close() + + var result struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } + return result.AccessToken, nil, nil +} + +func parseAuthHeader(header string) (string, map[string]string) { + parts := strings.SplitN(header, " ", 2) + scheme := strings.ToLower(parts[0]) + if len(parts) < 2 { + return scheme, nil + } + + params := make(map[string]string) + result := authHeaderRegex.FindAllStringSubmatch(parts[1], -1) + for _, match := range result { + params[strings.ToLower(match[1])] = match[2] + } + + return scheme, params +} diff --git a/pkg/registry/client.go b/pkg/registry/client.go new file mode 100644 index 000000000..5d5b3963e --- /dev/null +++ b/pkg/registry/client.go @@ -0,0 +1,22 @@ +package registry + +import ( + "net/http" +) + +// Client is a customized registry client +type Client struct { + base http.RoundTripper + plainHTTP bool +} + +// NewClient creates a new registry client +func NewClient(base http.RoundTripper, plainHTTP bool) *Client { + if base == nil { + base = http.DefaultTransport + } + return &Client{ + base: base, + plainHTTP: plainHTTP, + } +} diff --git a/pkg/registry/manifest.go b/pkg/registry/manifest.go new file mode 100644 index 000000000..f4248eb43 --- /dev/null +++ b/pkg/registry/manifest.go @@ -0,0 +1,84 @@ +package registry + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/distribution/distribution/v3/manifest/manifestlist" + "github.com/distribution/distribution/v3/manifest/schema2" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + artifactspec "github.com/oras-project/artifacts-spec/specs-go/v1" +) + +var supportedMediaTypes = []string{ + manifestlist.MediaTypeManifestList, + schema2.MediaTypeManifest, + ocispec.MediaTypeImageIndex, + ocispec.MediaTypeImageManifest, + artifactspec.MediaTypeArtifactManifest, +} + +// GetManifestDescriptor returns signature manifest information +func (c *Client) GetManifestDescriptor(ref Reference) (ocispec.Descriptor, error) { + scheme := "https" + if c.plainHTTP { + scheme = "http" + } + url := fmt.Sprintf("%s://%s/v2/%s/manifests/%s", + scheme, + ref.Host(), + ref.Repository, + ref.ReferenceOrDefault(), + ) + req, err := http.NewRequest(http.MethodHead, url, nil) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("invalid reference: %v", ref) + } + req.Header.Set("Connection", "close") + for _, mediaType := range supportedMediaTypes { + req.Header.Add("Accept", mediaType) + } + + resp, err := c.base.RoundTrip(req) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("%v: %v", url, err) + } + resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + // no op + case http.StatusUnauthorized, http.StatusNotFound: + return ocispec.Descriptor{}, fmt.Errorf("%v: %s", ref, resp.Status) + default: + return ocispec.Descriptor{}, fmt.Errorf("%v: %s", url, resp.Status) + } + + header := resp.Header + mediaType := header.Get("Content-Type") + if mediaType == "" { + return ocispec.Descriptor{}, fmt.Errorf("%v: missing Content-Type", url) + } + contentDigest := header.Get("Docker-Content-Digest") + if contentDigest == "" { + return ocispec.Descriptor{}, fmt.Errorf("%v: missing Docker-Content-Digest", url) + } + parsedDigest, err := digest.Parse(contentDigest) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("%v: invalid Docker-Content-Digest: %s", url, contentDigest) + } + length := header.Get("Content-Length") + if length == "" { + return ocispec.Descriptor{}, fmt.Errorf("%v: missing Content-Length", url) + } + size, err := strconv.ParseInt(length, 10, 64) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("%v: invalid Content-Length", url) + } + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: parsedDigest, + Size: size, + }, nil +} diff --git a/pkg/registry/reference.go b/pkg/registry/reference.go new file mode 100644 index 000000000..e09a9c41f --- /dev/null +++ b/pkg/registry/reference.go @@ -0,0 +1,78 @@ +package registry + +import ( + "errors" + "strings" + + "github.com/opencontainers/go-digest" +) + +// Reference references to a descriptor in the registry +type Reference struct { + // Registry is the name of the registry. + // It is usually the domain name of the registry. + Registry string + + // Repository is the name of the repository + Repository string + + // Reference is the reference of the object in the repository. + // A reference can be a tag and / or a digest. + Reference string +} + +func ParseReference(raw string) (Reference, error) { + parts := strings.SplitN(raw, "/", 2) + if len(parts) == 1 { + return Reference{}, errors.New("invalid reference") + } + registry, path := parts[0], parts[1] + var repository string + var reference string + if index := strings.Index(path, "@"); index != -1 { + repository = path[:index] + reference = path[index+1:] + } else if index := strings.Index(path, ":"); index != -1 { + repository = path[:index] + reference = path[index+1:] + } else { + repository = path + } + return Reference{ + Registry: registry, + Repository: repository, + Reference: reference, + }, nil +} + +// Host returns the host name of the registry +func (r Reference) Host() string { + if r.Registry == "docker.io" { + return "registry-1.docker.io" + } + return r.Registry +} + +// ReferenceOrDefault returns the reference or the default reference if empty +func (r Reference) ReferenceOrDefault() string { + if r.Reference == "" { + return "latest" + } + return r.Reference +} + +// Digest returns the reference as a digest +func (r Reference) Digest() (digest.Digest, error) { + return digest.Parse(r.Reference) +} + +func (r Reference) String() string { + ref := r.Registry + "/" + r.Repository + if r.Reference == "" { + return ref + } + if d, err := r.Digest(); err == nil { + return ref + "@" + d.String() + } + return ref + ":" + r.Reference +}