1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2025-02-07 13:31:37 +02:00

Merge pull request #460 from magiconair/sign-pipeline

feat: add artifact signing pipeline
This commit is contained in:
Carlos Alexandro Becker 2017-12-16 17:51:18 -02:00 committed by GitHub
commit 5e22ef1130
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 464 additions and 0 deletions

View File

@ -144,6 +144,14 @@ type FPM struct {
XXX map[string]interface{} `yaml:",inline"`
}
// Sign config
type Sign struct {
Cmd string `yaml:"cmd,omitempty"`
Args []string `yaml:"args,omitempty"`
Signature string `yaml:"signature,omitempty"`
Artifacts string `yaml:"artifacts,omitempty"`
}
// SnapcraftAppMetadata for the binaries that will be in the snap package
type SnapcraftAppMetadata struct {
Plugs []string
@ -238,6 +246,7 @@ type Project struct {
Artifactories []Artifactory `yaml:",omitempty"`
Changelog Changelog `yaml:",omitempty"`
Dist string `yaml:",omitempty"`
Sign Sign `yaml:",omitempty"`
// this is a hack ¯\_(ツ)_/¯
SingleBuild Build `yaml:"build,omitempty"`

View File

@ -37,6 +37,7 @@ type Context struct {
Git GitInfo
Binaries map[string]map[string][]Binary
Artifacts []string
Checksums []string
Dockers []string
ReleaseNotes string
Version string
@ -50,6 +51,7 @@ type Context struct {
var (
artifactsLock sync.Mutex
checksumsLock sync.Mutex
dockersLock sync.Mutex
binariesLock sync.Mutex
)
@ -63,6 +65,15 @@ func (ctx *Context) AddArtifact(file string) {
log.WithField("artifact", file).Info("new release artifact")
}
// AddChecksum adds a checksum file.
func (ctx *Context) AddChecksum(file string) {
checksumsLock.Lock()
defer checksumsLock.Unlock()
file = strings.TrimPrefix(file, ctx.Config.Dist+string(filepath.Separator))
ctx.Checksums = append(ctx.Checksums, file)
log.WithField("checksum", file).Info("new checksum file")
}
// AddDocker adds a docker image to the docker images list
func (ctx *Context) AddDocker(image string) {
dockersLock.Lock()

View File

@ -15,6 +15,9 @@ func TestMultipleAdds(t *testing.T) {
"dist/c",
"dist/d",
}
var checksums = []string{
"dist/a.sha256",
}
var dockerfiles = []string{
"a/b:1.0.0",
"c/d:2.0.0",
@ -32,6 +35,14 @@ func TestMultipleAdds(t *testing.T) {
})
}
assert.NoError(t, g.Wait())
for _, c := range checksums {
c := c
g.Go(func() error {
ctx.AddChecksum(c)
return nil
})
}
assert.NoError(t, g.Wait())
for _, d := range dockerfiles {
d := d
g.Go(func() error {
@ -42,6 +53,8 @@ func TestMultipleAdds(t *testing.T) {
assert.NoError(t, g.Wait())
assert.Len(t, ctx.Artifacts, len(artifacts))
assert.Contains(t, ctx.Artifacts, "a", "b", "c", "d")
assert.Len(t, ctx.Checksums, len(checksums))
assert.Contains(t, ctx.Checksums, "a.sha256")
assert.Len(t, ctx.Dockers, len(dockerfiles))
assert.Contains(t, ctx.Dockers, "a/b:1.0.0", "c/d:2.0.0", "e/f:3.0.0")
}

51
docs/075-sign.md Normal file
View File

@ -0,0 +1,51 @@
---
title: Signing
---
GoReleaser can sign some or all of the generated artifacts. Signing ensures
that the artifacts have been generated by yourself and your users can verify
that by comparing the generated signature with your public signing key.
Signing works in combination with checksum files and it is generally sufficient
to sign the checksum files only.
The default is configured to create a detached signature for the checksum files
with [GunPG](https://www.gnupg.org/) and your default key. To enable signing
just add
```yaml
# goreleaser.yml
sign:
artifacts: checksum
```
To customize the signing pipeline you can use the following options:
```yml
# .goreleaser.yml
sign:
# name of the signature file.
# '${in}' is the path to the artifact that should be signed.
#
# signature: "${artifact}.sig"
# path to the signature command
#
# cmd: gpg
# command line arguments for the command
#
# to sign with a specific key use
# args: ["-u", "<key id, fingerprint, email, ...>", "--output", "${signature}", "--detach-sign", "${artifact}"]
#
# args: ["--output", "${signature}", "--detach-sign", "${artifact}"]
# which artifacts to sign
#
# checksum: only checksum file(s)
# all: all artifacts
# none: no signing
#
# artifacts: none
```

View File

@ -25,6 +25,7 @@ import (
"github.com/goreleaser/goreleaser/pipeline/fpm"
"github.com/goreleaser/goreleaser/pipeline/git"
"github.com/goreleaser/goreleaser/pipeline/release"
"github.com/goreleaser/goreleaser/pipeline/sign"
"github.com/goreleaser/goreleaser/pipeline/snapcraft"
yaml "gopkg.in/yaml.v2"
)
@ -50,6 +51,7 @@ var pipes = []pipeline.Piper{
fpm.Pipe{}, // archive via fpm (deb, rpm, etc)
snapcraft.Pipe{}, // archive via snapcraft (snap)
checksums.Pipe{}, // checksums of the files
sign.Pipe{}, // sign artifacts
docker.Pipe{}, // create and push docker images
artifactory.Pipe{}, // push to artifactory
release.Pipe{}, // release to github

View File

@ -39,6 +39,7 @@ func (Pipe) Run(ctx *context.Context) (err error) {
log.WithError(err).Errorf("failed to close %s", file.Name())
}
ctx.AddArtifact(file.Name())
ctx.AddChecksum(file.Name())
}()
var g errgroup.Group
for _, artifact := range ctx.Artifacts {

View File

@ -14,6 +14,7 @@ import (
"github.com/goreleaser/goreleaser/pipeline/docker"
"github.com/goreleaser/goreleaser/pipeline/fpm"
"github.com/goreleaser/goreleaser/pipeline/release"
"github.com/goreleaser/goreleaser/pipeline/sign"
"github.com/goreleaser/goreleaser/pipeline/snapshot"
)
@ -31,6 +32,7 @@ var defaulters = []pipeline.Defaulter{
build.Pipe{},
fpm.Pipe{},
checksums.Pipe{},
sign.Pipe{},
docker.Pipe{},
artifactory.Pipe{},
brew.Pipe{},

97
pipeline/sign/sign.go Normal file
View File

@ -0,0 +1,97 @@
package sign
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/goreleaser/goreleaser/context"
"github.com/goreleaser/goreleaser/pipeline"
)
// Pipe for artifact signing.
type Pipe struct{}
func (Pipe) String() string {
return "signing artifacts"
}
// Default sets the Pipes defaults.
func (Pipe) Default(ctx *context.Context) error {
cfg := &ctx.Config.Sign
if cfg.Cmd == "" {
cfg.Cmd = "gpg"
}
if cfg.Signature == "" {
cfg.Signature = "${artifact}.sig"
}
if len(cfg.Args) == 0 {
cfg.Args = []string{"--output", "$signature", "--detach-sig", "$artifact"}
}
if cfg.Artifacts == "" {
cfg.Artifacts = "none"
}
return nil
}
// Run executes the Pipe.
func (Pipe) Run(ctx *context.Context) error {
switch ctx.Config.Sign.Artifacts {
case "checksum":
return sign(ctx, ctx.Checksums)
case "all":
return sign(ctx, ctx.Artifacts)
case "none":
return pipeline.Skip("artifact signing disabled")
default:
return fmt.Errorf("invalid list of artifacts to sign: %s", ctx.Config.Sign.Artifacts)
}
}
func sign(ctx *context.Context, artifacts []string) error {
var sigs []string
for _, a := range artifacts {
sig, err := signone(ctx, a)
if err != nil {
return err
}
sigs = append(sigs, sig)
}
for _, sig := range sigs {
ctx.AddArtifact(sig)
}
return nil
}
func signone(ctx *context.Context, artifact string) (string, error) {
cfg := ctx.Config.Sign
artifact = filepath.Join(ctx.Config.Dist, artifact)
env := map[string]string{
"artifact": artifact,
}
env["signature"] = expand(cfg.Signature, env)
var args []string
for _, a := range cfg.Args {
args = append(args, expand(a, env))
}
// The GoASTScanner flags this as a security risk.
// However, this works as intended. The nosec annotation
// tells the scanner to ignore this.
// #nosec
cmd := exec.Command(cfg.Cmd, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("sign: %s failed with %q", cfg.Cmd, string(output))
}
return env["signature"], nil
}
func expand(s string, env map[string]string) string {
return os.Expand(s, func(key string) string {
return env[key]
})
}

165
pipeline/sign/sign_test.go Normal file
View File

@ -0,0 +1,165 @@
package sign
import (
"bytes"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"testing"
"github.com/goreleaser/goreleaser/config"
"github.com/goreleaser/goreleaser/context"
"github.com/stretchr/testify/assert"
)
func TestDescription(t *testing.T) {
assert.NotEmpty(t, Pipe{}.String())
}
func TestSignDefault(t *testing.T) {
ctx := &context.Context{}
Pipe{}.Default(ctx)
assert.Equal(t, ctx.Config.Sign.Cmd, "gpg")
assert.Equal(t, ctx.Config.Sign.Signature, "${artifact}.sig")
assert.Equal(t, ctx.Config.Sign.Args, []string{"--output", "$signature", "--detach-sig", "$artifact"})
assert.Equal(t, ctx.Config.Sign.Artifacts, "none")
}
func TestSignDisabled(t *testing.T) {
ctx := &context.Context{}
ctx.Config.Sign.Artifacts = "none"
err := Pipe{}.Run(ctx)
assert.EqualError(t, err, "artifact signing disabled")
}
func TestSignInvalidArtifacts(t *testing.T) {
ctx := &context.Context{}
ctx.Config.Sign.Artifacts = "foo"
err := Pipe{}.Run(ctx)
assert.EqualError(t, err, "invalid list of artifacts to sign: foo")
}
func TestSignArtifacts(t *testing.T) {
// fix permission on keyring dir to suppress warning about insecure permissions
if err := os.Chmod(keyring, 0700); err != nil {
t.Fatal("Chmod: ", err)
}
tests := []struct {
desc string
ctx *context.Context
signatures []string
}{
{
desc: "sign all artifacts",
ctx: &context.Context{
Config: config.Project{
Sign: config.Sign{Artifacts: "all"},
},
Artifacts: []string{"artifact1", "artifact2", "checksum"},
Checksums: []string{"checksum"},
},
signatures: []string{"artifact1.sig", "artifact2.sig", "checksum.sig"},
},
{
desc: "sign only checksums",
ctx: &context.Context{
Config: config.Project{
Sign: config.Sign{Artifacts: "checksum"},
},
Artifacts: []string{"artifact1", "artifact2", "checksum"},
Checksums: []string{"checksum"},
},
signatures: []string{"checksum.sig"},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
testSign(t, tt.ctx, tt.signatures)
})
}
}
const keyring = "testdata/gnupg"
const user = "nopass"
func testSign(t *testing.T, ctx *context.Context, signatures []string) {
// create temp dir for file and signature
tmpdir, err := ioutil.TempDir("", "goreleaser")
if err != nil {
t.Fatal("TempDir: ", err)
}
defer os.RemoveAll(tmpdir)
ctx.Config.Dist = tmpdir
// create some fake artifacts
artifacts := ctx.Artifacts
for _, f := range artifacts {
file := filepath.Join(tmpdir, f)
if err2 := ioutil.WriteFile(file, []byte("foo"), 0644); err2 != nil {
t.Fatal("WriteFile: ", err2)
}
}
// configure the pipeline
// make sure we are using the test keyring
err = Pipe{}.Default(ctx)
if err != nil {
t.Fatal("Default: ", err)
}
ctx.Config.Sign.Args = append([]string{"--homedir", keyring}, ctx.Config.Sign.Args...)
// run the pipeline
err = Pipe{}.Run(ctx)
if err != nil {
t.Fatal("Run: ", err)
}
// verify that only the artifacts and the signatures are in the dist dir
files, err := ioutil.ReadDir(tmpdir)
if err != nil {
t.Fatal("ReadDir: ", err)
}
gotFiles := []string{}
for _, f := range files {
gotFiles = append(gotFiles, f.Name())
}
wantFiles := append(artifacts, signatures...)
sort.Strings(wantFiles)
assert.Equal(t, wantFiles, gotFiles)
// verify the signatures
for _, sig := range signatures {
verifySignature(t, ctx, sig)
}
// check signature is an artifact
assert.Equal(t, ctx.Artifacts, append(artifacts, signatures...))
}
func verifySignature(t *testing.T, ctx *context.Context, sig string) {
artifact := sig[:len(sig)-len(".sig")]
// verify signature was made with key for usesr 'nopass'
cmd := exec.Command("gpg", "--homedir", keyring, "--verify", filepath.Join(ctx.Config.Dist, sig), filepath.Join(ctx.Config.Dist, artifact))
out, err := cmd.CombinedOutput()
if err != nil {
t.Log(string(out))
t.Fatal("verify: ", err)
}
// check if the signature matches the user we expect to do this properly we
// might need to have either separate keyrings or export the key from the
// keyring before we do the verification. For now we punt and look in the
// output.
if !bytes.Contains(out, []byte(user)) {
t.Fatalf("signature is not from %s", user)
}
}

1
pipeline/sign/testdata/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
S-*

80
pipeline/sign/testdata/README.md vendored Normal file
View File

@ -0,0 +1,80 @@
# Creating test keys for GnuPG
The unit tests needs a test key to work with. I have tried to create a test keyring
on the fly and while that worked I was not able to successfully sign with that.
gpg would bail with an ioctl error which I didn't track down since using a static
key works.
This uses the `--homedir .` option to create the test keys so that we do not touch
the local keyring file.
1. Create signing keys
cd $GOPATH/src/github.com/goreleaser/goreleaser/pipeline/sign/testdata/gnupg
gpg --homedir . --quick-generate-key --batch --passphrase '' nopass default default 10y
1. Check that the key exists
## $ gpg --homedir . --list-keys
pub rsa2048 2017-12-13 [SC][expires: 2027-12-11]
FB6BEDFCECE1761EDD68BF32EF2D274B0EDAAE12
uid [ultimate] nopass
sub rsa2048 2017-12-13 [E]
1) Check that signing works
# create a test file
echo "bar" > foo
# sign and verfiy
gpg --homedir . --detach-sign foo
gpg --homedir . --verify foo.sig foo
gpg: Signature made Wed Dec 13 22:02:49 2017 CET
gpg: using RSA key FB6BEDFCECE1761EDD68BF32EF2D274B0EDAAE12
gpg: Good signature from "nopass" [ultimate]
# cleanup
rm foo foo.sig
1) Make sure you have keyrings for both gpg1 and gpg2
travis-ci.org runs on an old Ubuntu installation which
has gpg 1.4 installed. We need to provide keyrings that
have the same keys and users for both formats.
This demonstrates the conversion from gpg2 to gpg1
format but should work the same the other way around.
# get gpg version
gpg --version
gpg (GnuPG) 2.2.3
...
# install gpg1
brew install gpg1
# brew install gpg2 # if you have gpg1 installed
# migrate the keys from gpg2 to gpg1
gpg --homedir . --export nopass | gpg1 --homedir . --import
gpg --homedir . --export-secret-key nopass | gpg1 --homedir . --import
# check keys are the same
gpg --homedir . --list-keys --keyid-format LONG
gpg1 --homedir . --list-keys --keyid-format LONG
gpg --homedir . --list-secret-keys --keyid-format LONG
gpg1 --homedir . --list-secret-keys --keyid-format LONG
```
```

View File

View File

@ -0,0 +1,32 @@
This is a revocation certificate for the OpenPGP key:
pub rsa2048 2017-12-13 [SC] [expires: 2027-12-11]
23E7505EC0A490C582CB9B27F1D733BF0B343347
uid nopass
A revocation certificate is a kind of "kill switch" to publicly
declare that a key shall not anymore be used. It is not possible
to retract such a revocation certificate once it has been published.
Use it to revoke this key in case of a compromise or loss of
the secret key. However, if the secret key is still accessible,
it is better to generate a new revocation certificate and give
a reason for the revocation. For details see the description of
of the gpg command "--generate-revocation" in the GnuPG manual.
To avoid an accidental use of this file, a colon has been inserted
before the 5 dashes below. Remove this colon with a text editor
before importing and publishing this revocation certificate.
:-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: This is a revocation certificate
iQE2BCABCAAgFiEEI+dQXsCkkMWCy5sn8dczvws0M0cFAloxnpUCHQAACgkQ8dcz
vws0M0dbdwf+KevSTLl688bOMMKcexoUra4porA/JJvtrkQAdHWqspqf+lQ+1rK0
Y4YNHkXixv6+R0aoECSrnx4ehk9nLH7hx5423DXEvmPkF70rWkF0eGeG8gVrUz2O
YWobOMldWPk6QPZ6rV5c5PdQSCx8+WHVXu/ym7u70fbmJV4IHuFlKiFUXsGk2PYj
k38ssFeqGo1bNZlIfuCggOurpfSXhKAsLRHjJVQe7ZowmloPiwHFfMwtuiBWpsZ/
3niEy3A5mdBC+ebtEL1KyaqjV6IN58YT3z0aBnDjA/qeweeT2jKjEWqHAPsJaJke
AyC0e8t71lnH4RZyKyfsBveKFfC4KN9SUA==
=DnZE
-----END PGP PUBLIC KEY BLOCK-----

BIN
pipeline/sign/testdata/gnupg/pubring.gpg vendored Normal file

Binary file not shown.

BIN
pipeline/sign/testdata/gnupg/pubring.kbx vendored Normal file

Binary file not shown.

BIN
pipeline/sign/testdata/gnupg/secring.gpg vendored Normal file

Binary file not shown.

BIN
pipeline/sign/testdata/gnupg/trustdb.gpg vendored Normal file

Binary file not shown.