Repository: goreleaser/nfpm
Pull request #695 (2023-08-02 21:22:42)

feat: Add support for remote signing keys
When used as a library, `nfpm.PackageSignature.SignFn` can be set as an alternative to `KeyFile`. This allows arbitrary signing key implementations, like a remote signing server.

Updates https://github.com/tailscale/tailscale/issues/1882

awly: @caarlos0 thanks!
I think you'll need to approve CI checks to run and merge the PR, since I don't have permission to do either ;)
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#695](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (823a95e) into [main](https://app.codecov.io/gh/goreleaser/nfpm/commit/577ae455317ec9fb51591fcf15d92586aad2ad17?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (577ae45) will **increase** coverage by `0.21%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main     #695      +/-   ##
==========================================
+ Coverage   74.80%   75.02%   +0.21%     
==========================================
  Files          10       10              
  Lines        2385     2406      +21     
==========================================
+ Hits         1784     1805      +21     
  Misses        425      425              
  Partials      176      176              
```


| [Files Changed](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [nfpm.go](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-bmZwbS5nbw==) | `85.00% <ø> (ø)` | |
| [apk/apk.go](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-YXBrL2Fway5nbw==) | `73.79% <100.00%> (+0.55%)` | :arrow_up: |
| [deb/deb.go](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-ZGViL2RlYi5nbw==) | `72.42% <100.00%> (+0.61%)` | :arrow_up: |
| [rpm/rpm.go](https://app.codecov.io/gh/goreleaser/nfpm/pull/695?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cnBtL3JwbS5nbw==) | `70.92% <100.00%> (+0.41%)` | :arrow_up: |



Patch:
diff --git a/apk/apk.go b/apk/apk.go
index 39c1798..a7c9c81 100644
--- a/apk/apk.go
+++ b/apk/apk.go
@@ -132,7 +132,7 @@ func (*Apk) Package(info *nfpm.Info, apk io.Writer) (err error) {
 		return err
 	}
 
-	if info.APK.Signature.KeyFile == "" {
+	if info.APK.Signature.KeyFile == "" && info.APK.Signature.SignFn == nil {
 		return combineToApk(apk, &bufControl, &bufData)
 	}
 
@@ -267,8 +267,14 @@ var errNoKeyAddress = errors.New("key name not set and maintainer mail address e
 
 func createSignatureBuilder(digest []byte, info *nfpm.Info) func(*tar.Writer) error {
 	return func(tw *tar.Writer) error {
-		signature, err := sign.RSASignSHA1Digest(digest,
-			info.APK.Signature.KeyFile, info.APK.Signature.KeyPassphrase)
+		var signature []byte
+		var err error
+		if signFn := info.APK.Signature.SignFn; signFn != nil {
+			signature, err = signFn(bytes.NewReader(digest))
+		} else {
+			signature, err = sign.RSASignSHA1Digest(digest,
+				info.APK.Signature.KeyFile, info.APK.Signature.KeyPassphrase)
+		}
 		if err != nil {
 			return err
 		}
diff --git a/deb/deb.go b/deb/deb.go
index 7ee344b..625ad9f 100644
--- a/deb/deb.go
+++ b/deb/deb.go
@@ -136,7 +136,7 @@ func (d *Deb) Package(info *nfpm.Info, deb io.Writer) (err error) { // nolint: f
 		return fmt.Errorf("cannot add data.tar.gz to deb: %w", err)
 	}
 
-	if info.Deb.Signature.KeyFile != "" {
+	if info.Deb.Signature.KeyFile != "" || info.Deb.Signature.SignFn != nil {
 		sig, sigType, err := doSign(info, debianBinary, controlTarGz, dataTarball)
 		if err != nil {
 			return err
@@ -172,7 +172,12 @@ func dpkgSign(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) (
 		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
 	}
 
-	sig, err := sign.PGPClearSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
+	var sig []byte
+	if signFn := info.Deb.Signature.SignFn; signFn != nil {
+		sig, err = signFn(data)
+	} else {
+		sig, err = sign.PGPClearSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
+	}
 	if err != nil {
 		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
 	}
@@ -193,7 +198,13 @@ func debSign(info *nfpm.Info, debianBinary, controlTarGz, dataTarball []byte) ([
 		}
 	}
 
-	sig, err := sign.PGPArmoredDetachSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
+	var sig []byte
+	var err error
+	if signFn := info.Deb.Signature.SignFn; signFn != nil {
+		sig, err = signFn(data)
+	} else {
+		sig, err = sign.PGPArmoredDetachSignWithKeyID(data, info.Deb.Signature.KeyFile, info.Deb.Signature.KeyPassphrase, info.Deb.Signature.KeyID)
+	}
 	if err != nil {
 		return nil, sigType, &nfpm.ErrSigningFailure{Err: err}
 	}
diff --git a/nfpm.go b/nfpm.go
index caba9f2..f99c6c9 100644
--- a/nfpm.go
+++ b/nfpm.go
@@ -351,6 +351,13 @@ type PackageSignature struct {
 	KeyFile       string  `yaml:"key_file,omitempty" json:"key_file,omitempty" jsonschema:"title=key file,example=key.gpg"`
 	KeyID         *string `yaml:"key_id,omitempty" json:"key_id,omitempty" jsonschema:"title=key id,example=bc8acdd415bd80b3"`
 	KeyPassphrase string  `yaml:"-" json:"-"` // populated from environment variable
+	// SignFn, if set, will be called with the package-specific data to sign.
+	// For deb and rpm packages, data is the full package content.
+	// For apk packages, data is the SHA1 digest of control tgz.
+	//
+	// This allows for signing implementations other than using a local file
+	// (for example using a remote signer like KMS).
+	SignFn func(data io.Reader) ([]byte, error) `yaml:"-" json:"-"` // populated when used as a library
 }
 
 type RPMSignature struct {
diff --git a/rpm/rpm.go b/rpm/rpm.go
index 27570d6..97953bf 100644
--- a/rpm/rpm.go
+++ b/rpm/rpm.go
@@ -126,6 +126,11 @@ func (*RPM) Package(info *nfpm.Info, w io.Writer) (err error) {
 			info.RPM.Signature.KeyID,
 		))
 	}
+	if signFn := info.RPM.Signature.SignFn; signFn != nil {
+		rpm.SetPGPSigner(func(data []byte) ([]byte, error) {
+			return signFn(bytes.NewReader(data))
+		})
+	}
 
 	if err = createFilesInsideRPM(info, rpm); err != nil {
 		return err


Test patch:
diff --git a/apk/apk_test.go b/apk/apk_test.go
index 56043f1..f94bbe2 100644
--- a/apk/apk_test.go
+++ b/apk/apk_test.go
@@ -363,6 +363,33 @@ func TestSignatureError(t *testing.T) {
 	require.True(t, errors.As(err, &expectedError))
 }
 
+func TestSignatureCallback(t *testing.T) {
+	info := exampleInfo()
+	info.APK.Signature.SignFn = func(r io.Reader) ([]byte, error) {
+		digest, err := io.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		return sign.RSASignSHA1Digest(digest, "../internal/sign/testdata/rsa.priv", "hunter2")
+	}
+	info.APK.Signature.KeyName = "testkey.rsa.pub"
+	err := nfpm.PrepareForPackager(info, "apk")
+	require.NoError(t, err)
+
+	digest := sha1.New().Sum(nil) // nolint:gosec
+
+	var signatureTarGz bytes.Buffer
+	tw := tar.NewWriter(&signatureTarGz)
+	require.NoError(t, createSignatureBuilder(digest, info)(tw))
+
+	signature := extractFromTar(t, signatureTarGz.Bytes(), ".SIGN.RSA.testkey.rsa.pub")
+	err = sign.RSAVerifySHA1Digest(digest, signature, "../internal/sign/testdata/rsa.pub")
+	require.NoError(t, err)
+
+	err = Default.Package(info, io.Discard)
+	require.NoError(t, err)
+}
+
 func TestDisableGlobbing(t *testing.T) {
 	info := exampleInfo()
 	info.DisableGlobbing = true
diff --git a/deb/deb_test.go b/deb/deb_test.go
index cc6acda..6769deb 100644
--- a/deb/deb_test.go
+++ b/deb/deb_test.go
@@ -960,6 +960,28 @@ func TestDebsigsSignatureError(t *testing.T) {
 	require.True(t, errors.As(err, &expectedError))
 }
 
+func TestDebsigsSignatureCallback(t *testing.T) {
+	info := exampleInfo()
+	info.Deb.Signature.SignFn = func(r io.Reader) ([]byte, error) {
+		return sign.PGPArmoredDetachSignWithKeyID(r, "../internal/sign/testdata/privkey.asc", "hunter2", nil)
+	}
+
+	var deb bytes.Buffer
+	err := Default.Package(info, &deb)
+	require.NoError(t, err)
+
+	debBinary := extractFileFromAr(t, deb.Bytes(), "debian-binary")
+	controlTarGz := extractFileFromAr(t, deb.Bytes(), "control.tar.gz")
+	dataTarball := extractFileFromAr(t, deb.Bytes(), findDataTarball(t, deb.Bytes()))
+	signature := extractFileFromAr(t, deb.Bytes(), "_gpgorigin")
+
+	message := io.MultiReader(bytes.NewReader(debBinary),
+		bytes.NewReader(controlTarGz), bytes.NewReader(dataTarball))
+
+	err = sign.PGPVerify(message, signature, "../internal/sign/testdata/pubkey.asc")
+	require.NoError(t, err)
+}
+
 func TestDpkgSigSignature(t *testing.T) {
 	info := exampleInfo()
 	info.Deb.Signature.KeyFile = "../internal/sign/testdata/privkey.asc"
@@ -990,6 +1012,24 @@ func TestDpkgSigSignatureError(t *testing.T) {
 	require.True(t, errors.As(err, &expectedError))
 }
 
+func TestDpkgSigSignatureCallback(t *testing.T) {
+	info := exampleInfo()
+	info.Deb.Signature.SignFn = func(r io.Reader) ([]byte, error) {
+		return sign.PGPClearSignWithKeyID(r, "../internal/sign/testdata/privkey.asc", "hunter2", nil)
+	}
+	info.Deb.Signature.Method = "dpkg-sig"
+	info.Deb.Signature.Signer = "bob McRobert"
+
+	var deb bytes.Buffer
+	err := Default.Package(info, &deb)
+	require.NoError(t, err)
+
+	signature := extractFileFromAr(t, deb.Bytes(), "_gpgbuilder")
+
+	err = sign.PGPReadMessage(signature, "../internal/sign/testdata/pubkey.asc")
+	require.NoError(t, err)
+}
+
 func TestDisableGlobbing(t *testing.T) {
 	info := exampleInfo()
 	info.DisableGlobbing = true
diff --git a/rpm/rpm_test.go b/rpm/rpm_test.go
index 3c255aa..314da90 100644
--- a/rpm/rpm_test.go
+++ b/rpm/rpm_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/goreleaser/chglog"
 	"github.com/goreleaser/nfpm/v2"
 	"github.com/goreleaser/nfpm/v2/files"
+	"github.com/goreleaser/nfpm/v2/internal/sign"
 	"github.com/stretchr/testify/require"
 )
 
@@ -743,6 +744,32 @@ func TestRPMSignatureError(t *testing.T) {
 	require.True(t, errors.As(err, &expectedError))
 }
 
+func TestRPMSignatureCallback(t *testing.T) {
+	info := exampleInfo()
+	info.RPM.Signature.SignFn = func(r io.Reader) ([]byte, error) {
+		data, err := io.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		return sign.PGPSignerWithKeyID("../internal/sign/testdata/privkey.asc", "hunter2", nil)(data)
+	}
+
+	pubkeyFileContent, err := os.ReadFile("../internal/sign/testdata/pubkey.gpg")
+	require.NoError(t, err)
+
+	keyring, err := openpgp.ReadKeyRing(bytes.NewReader(pubkeyFileContent))
+	require.NoError(t, err)
+	require.NotNil(t, keyring, "cannot verify sigs with an empty keyring")
+
+	var rpmBuffer bytes.Buffer
+	err = Default.Package(info, &rpmBuffer)
+	require.NoError(t, err)
+
+	_, sigs, err := rpmutils.Verify(bytes.NewReader(rpmBuffer.Bytes()), keyring)
+	require.NoError(t, err)
+	require.Len(t, sigs, 2)
+}
+
 func TestRPMGhostFiles(t *testing.T) {
 	filename := "/usr/lib/casper.a"