From bdbcf6656983ed3f26e22c7cfcc1363aeec58941 Mon Sep 17 00:00:00 2001 From: Yusuke Kuoka Date: Mon, 16 May 2022 08:40:41 +0900 Subject: [PATCH] chore: Add signrel command for sigining arc release assets (#1426) * chore: Add signrel command for sigining arc release assets I used this command to sign assets for the recent releases to comply with the recommendation of https://github.com/ossf/scorecard/blob/5758364c82f7fc72b256f9a8cfc89dc550d7dd66/docs/checks.md#signed-releases Ref #1298 * Implement signrel subcommands for listing tags and signing assets, with docs --- hack/signrel/README.md | 67 +++++++++ hack/signrel/go.mod | 3 + hack/signrel/main.go | 325 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 hack/signrel/README.md create mode 100644 hack/signrel/go.mod create mode 100644 hack/signrel/main.go diff --git a/hack/signrel/README.md b/hack/signrel/README.md new file mode 100644 index 00000000..abf08f8e --- /dev/null +++ b/hack/signrel/README.md @@ -0,0 +1,67 @@ +# signrel + +`signrel` is the utility command for downloading `actions-runner-controller` release assets, sigining those, and uploading the signature files. + +## Verifying Release Assets + +For users, browse https://keys.openpgp.org/search?q=D8078411E3D8400B574EDB0441B69B728F095A87 and download the public key, or refer to [the instruction](https://keys.openpgp.org/about/usage#gnupg-retrieve) to import the key onto your machine. + +Next, you'll want to verify the signature of the download asset somehow. + +With `gpg`, you would usually do that by downloading both the asset and the signature files from our specific release page, and run `gpg --verify` like: + +```console +# Download the asset +curl -LO https://github.com/actions-runner-controller/actions-runner-controller/releases/download/v0.23.0/actions-runner-controller.yaml + +# Download the signature file +curl -LO https://github.com/actions-runner-controller/actions-runner-controller/releases/download/v0.23.0/actions-runner-controller.yaml.asc + +# Verify +gpg --verify actions-runner-controller.yaml{.asc,} +``` + +On succesful verification, the gpg command would output: + +``` +gpg: Signature made Tue 10 May 2022 04:15:32 AM UTC +gpg: using RSA key D8078411E3D8400B574EDB0441B69B728F095A87 +gpg: checking the trustdb +gpg: marginals needed: 3 completes needed: 1 trust model: pgp +gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u +gpg: next trustdb check due at 2024-05-09 +gpg: Good signature from "Yusuke Kuoka " [ultimate] +``` + +## Signing Release Assets + +Assuming you are a maintainer of the project who has admin permission, run the command like the below to sign assets and upload the signature files: + +```console +$ cd hack/signrel + +$ for v in v0.23.0 actions-runner-controller-0.18.0 v0.22.3 v0.22.2 actions-runner-controller-0.17.2; do TAG=$v go run . sign; done + +Downloading actions-runner-controller.yaml to downloads/v0.23.0/actions-runner-controller.yaml +Uploading downloads/v0.23.0/actions-runner-controller.yaml.asc +downloads/v0.23.0/actions-runner-controller.yaml.asc has been already uploaded +Downloading actions-runner-controller-0.18.0.tgz to downloads/actions-runner-controller-0.18.0/actions-runner-controller-0.18.0.tgz +Uploading downloads/actions-runner-controller-0.18.0/actions-runner-controller-0.18.0.tgz.asc +Upload completed: *snip* +Downloading actions-runner-controller.yaml to downloads/v0.22.3/actions-runner-controller.yaml +Uploading downloads/v0.22.3/actions-runner-controller.yaml.asc +Upload completed: *snip* +Downloading actions-runner-controller.yaml to downloads/v0.22.2/actions-runner-controller.yaml +Uploading downloads/v0.22.2/actions-runner-controller.yaml.asc +Upload completed: *snip* +Downloading actions-runner-controller-0.17.2.tgz to downloads/actions-runner-controller-0.17.2/actions-runner-controller-0.17.2.tgz +Uploading downloads/actions-runner-controller-0.17.2/actions-runner-controller-0.17.2.tgz.asc +Upload completed: *snip* +actions-runner-controller-0.17.2.tgz.asc"} +``` + +To retrieve all the available release tags, run: + +``` +$ go run . tags | jq -r .[].tag_name +``` diff --git a/hack/signrel/go.mod b/hack/signrel/go.mod new file mode 100644 index 00000000..b48ba733 --- /dev/null +++ b/hack/signrel/go.mod @@ -0,0 +1,3 @@ +module github.com/actions-runner-controller/actions-runner-controller/hack/sigrel + +go 1.17 diff --git a/hack/signrel/main.go b/hack/signrel/main.go new file mode 100644 index 00000000..4e26ead6 --- /dev/null +++ b/hack/signrel/main.go @@ -0,0 +1,325 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func main() { + a := &githubReleaseAsset{} + + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "Invalid command: %v\n", os.Args) + fmt.Fprintf(os.Stderr, "USAGE: signrel [list-tags|sign-assets]\n") + os.Exit(2) + } + + switch cmd := os.Args[1]; cmd { + case "tags": + listTags(a) + case "sign": + tag := os.Getenv("TAG") + sign(a, tag) + default: + fmt.Fprintf(os.Stderr, "Unknown command %s\n", cmd) + os.Exit(2) + } +} + +func listTags(a *githubReleaseAsset) { + _, err := a.getRecentReleases(owner, repo) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func sign(a *githubReleaseAsset, tag string) { + if err := a.Download(tag, "downloads"); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +type Release struct { + ID int64 `json:"id"` +} + +type Asset struct { + Name string `json:"name"` + ID int64 `json:"id"` + URL string `json:"url"` +} + +type AssetsResponse struct { + Assets []Asset +} + +type githubReleaseAsset struct { +} + +const ( + owner = "actions-runner-controller" + repo = "actions-runner-controller" +) + +// GetFile downloads the give URL into the given path. The URL must +// reference a single file. If possible, the Getter should check if +// the remote end contains the same file and no-op this operation. +func (a *githubReleaseAsset) Download(tag string, dstDir string) error { + release, err := a.getReleaseByTag(owner, repo, tag) + if err != nil { + return err + } + + assets, err := a.getAssetsByReleaseID(owner, repo, release.ID) + if err != nil { + return err + } + + d := filepath.Join(dstDir, tag) + if err := os.MkdirAll(d, 0755); err != nil { + return err + } + + for _, asset := range assets { + if strings.HasSuffix(asset.Name, ".asc") { + continue + } + + p := filepath.Join(d, asset.Name) + fmt.Fprintf(os.Stderr, "Downloading %s to %s\n", asset.Name, p) + if err := a.getFile(p, owner, repo, asset.ID); err != nil { + return err + } + + if info, _ := os.Stat(p + ".asc"); info == nil { + _, err := a.sign(p) + if err != nil { + return err + } + } + + sig := p + ".asc" + + fmt.Fprintf(os.Stdout, "Uploading %s\n", sig) + + if err := a.upload(sig, release.ID); err != nil { + return err + } + } + + return nil +} + +func (a *githubReleaseAsset) getRecentReleases(owner, repo string) (*Release, error) { + reqURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + if gt := os.Getenv("GITHUB_TOKEN"); gt != "" { + req.Header = make(http.Header) + req.Header.Add("authorization", "token "+gt) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", reqURL, res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + fmt.Fprintf(os.Stdout, "%s\n", string(body)) + + return nil, nil +} + +func (a *githubReleaseAsset) getReleaseByTag(owner, repo, tag string) (*Release, error) { + var release Release + + reqURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + if gt := os.Getenv("GITHUB_TOKEN"); gt != "" { + req.Header = make(http.Header) + req.Header.Add("authorization", "token "+gt) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", reqURL, res.Status) + } + + d := json.NewDecoder(res.Body) + + if err := d.Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} + +func (a *githubReleaseAsset) getAssetsByReleaseID(owner, repo string, releaseID int64) ([]Asset, error) { + var assets []Asset + + reqURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/%d/assets", owner, repo, releaseID) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + if gt := os.Getenv("GITHUB_TOKEN"); gt != "" { + req.Header = make(http.Header) + req.Header.Add("authorization", "token "+gt) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", reqURL, res.Status) + } + + d := json.NewDecoder(res.Body) + + if err := d.Decode(&assets); err != nil { + return nil, err + } + + return assets, nil +} + +func (a *githubReleaseAsset) getFile(dst string, owner, repo string, assetID int64) error { + // Create all the parent directories if needed + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + + req, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/assets/%d", owner, repo, assetID), nil) + if err != nil { + log.Fatal(err) + } + req.Header = make(http.Header) + if gt := os.Getenv("GITHUB_TOKEN"); gt != "" { + req.Header.Add("authorization", "token "+gt) + } + req.Header.Add("accept", "application/octet-stream") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + f, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE, os.FileMode(0666)) + if err != nil { + return fmt.Errorf("open file %s: %w", dst, err) + } + defer f.Close() + + if _, err := io.Copy(f, res.Body); err != nil { + return err + } + + return nil +} + +func (a *githubReleaseAsset) sign(path string) (string, error) { + pass := os.Getenv("SIGNREL_PASSWORD") + cmd := exec.Command("gpg", "--armor", "--detach-sign", "--pinentry-mode", "loopback", "--passphrase", pass, path) + cap, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "gpg: %s", string(cap)) + return "", err + } + return path + ".asc", nil +} + +func (a *githubReleaseAsset) upload(sig string, releaseID int64) error { + assetName := filepath.Base(sig) + url := fmt.Sprintf("https://uploads.github.com/repos/%s/%s/releases/%d/assets?name=%s", owner, repo, releaseID, assetName) + f, err := os.Open(sig) + if err != nil { + return err + } + defer f.Close() + + size, err := f.Seek(0, 2) + if err != nil { + return err + } + + _, err = f.Seek(0, 0) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, f) + if err != nil { + log.Fatal(err) + } + req.Header = make(http.Header) + if gt := os.Getenv("GITHUB_TOKEN"); gt != "" { + req.Header.Add("authorization", "token "+gt) + } + req.Header.Add("content-type", "application/octet-stream") + + req.ContentLength = size + req.Header.Add("accept", "application/vnd.github.v3+json") + + // blob, _ := httputil.DumpRequestOut(req, true) + // fmt.Printf("%s\n", blob) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + if res.StatusCode == 422 { + fmt.Fprintf(os.Stdout, "%s has been already uploaded\n", sig) + return nil + } + + if res.StatusCode >= 300 { + return fmt.Errorf("unexpected http status %d: %s", res.StatusCode, body) + } + + fmt.Fprintf(os.Stdout, "Upload completed: %s\n", body) + + return err +}