Conversation
b1a4386 to
2cebadb
Compare
|
@Elara6331 Do you want to take a look at this? |
|
@caarlos0 Is there any chance to get this reviewed and merged? It's a very straight forward change |
caarlos0
left a comment
There was a problem hiding this comment.
looking good overall, a couple of comments though.
thanks for the PR 🙏🏻
| key, err := readSigningKey(keyFile, passphrase) | ||
| sig, err := PGPArmoredDetachSignWithKeyID(bytes.NewReader(data), keyFile, passphrase, hexKeyID) | ||
| if err != nil { | ||
| return nil, &nfpm.ErrSigningFailure{Err: err} |
There was a problem hiding this comment.
changing the error types returned is a breaking change. I don't think its really necessary...
There was a problem hiding this comment.
I didn't change the error type here. This is the helper function used as RPM signer that wraps the error. Maybe we should extract it to the RPM module?
| DefaultHash: crypto.SHA256, | ||
| }, | ||
| ); err != nil { | ||
| return nil, &nfpm.ErrSigningFailure{Err: err} |
|
Thanks! I'll go over it in the next few days |
3b0eeb6 to
62030db
Compare
Archlinux packages can now be signed with a detached PGP signature, producing a binary .sig file alongside the package — matching the format expected by pacman-key --verify. The signing reads back the finalized .pkg.tar.zst from disk via info.Target to avoid buffering the entire package in memory. The passphrase is taken from $NFPM_ARCHLINUX_PASSPHRASE with a fallback to $NFPM_PASSPHRASE, consistent with deb/rpm/apk. Also adds sign.PGPDetachedSignWithKeyID, a streaming variant of PGPSignerWithKeyID that accepts an io.Reader instead of []byte. See goreleaser#628
62030db to
4494ec6
Compare
|
@caarlos0 I rebased the branch, fixed your comments and the lint errors and added an acceptance test for signed Arch Linux packages |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1065 +/- ##
==========================================
- Coverage 73.97% 73.72% -0.25%
==========================================
Files 22 22
Lines 2778 2824 +46
==========================================
+ Hits 2055 2082 +27
- Misses 497 507 +10
- Partials 226 235 +9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds detached PGP signature generation for Arch Linux (.pkg.tar.zst) packages, producing a companion .sig file suitable for pacman-key --verify, and extends the signing utilities to support streaming signing from an io.Reader.
Changes:
- Add
archlinux.signatureconfiguration + schema/docs updates, with passphrase support via$NFPM_ARCHLINUX_PASSPHRASE(fallback$NFPM_PASSPHRASE). - Implement Arch Linux package signing that re-reads the finalized package from
info.Targetand writes<target>.sig. - Introduce
sign.PGPDetachedSignWithKeyID(io.Reader, ...)and add unit/acceptance tests for detached signature verification.
Show a summary per file
| File | Description |
|---|---|
| www/static/schema.json | Adds ArchLinuxSignature definition and archlinux.signature field to the JSON schema. |
| www/content/docs/configuration.md | Documents archlinux.signature configuration and passphrase environment variables. |
| testdata/acceptance/core.signed.yaml | Enables Arch Linux signing in the shared “signed” acceptance config. |
| testdata/acceptance/archlinux.dockerfile | Updates the Arch Linux “signed” acceptance stage to verify the generated .sig. |
| nfpm.go | Adds ArchLinuxSignature config wiring + env var expansion/passphrase population. |
| internal/sign/pgp.go | Adds streaming detached-sign helper and refactors rpm-compatible signer to use it. |
| internal/sign/pgp_test.go | Adds unit test for detached signing + verification. |
| arch/arch.go | Implements signature creation by reading the built package from disk and writing <target>.sig. |
| arch/arch_test.go | Adds unit tests for Arch signing success, error propagation, and callback signing. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 9/9 changed files
- Comments generated: 5
- Review effort level: Low
| // finalize the tar/zstd writer before creating the signature | ||
| if err = tw.Close(); err != nil { | ||
| return fmt.Errorf("closing data tarball: %w", err) | ||
| } | ||
| if err = zw.Close(); err != nil { | ||
| return fmt.Errorf("closing zstd writer: %w", err) | ||
| } |
| f, err := os.Open(info.Target) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("open package for signing: %w", err) | ||
| } |
| _, err = sigFile.Write(sig) | ||
| if err != nil { | ||
| return fmt.Errorf("write signature to file: %w", err) | ||
| } |
| keyID, err := parseKeyID(hexKeyID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("%v is not a valid key id: %w", hexKeyID, err) | ||
| } |
| if testCase.keyID != nil { | ||
| pgpSignature := crypto.NewPGPSignature(sig) | ||
|
|
||
| sigID, _ := pgpSignature.GetSignatureKeyIDs() |
caarlos0
left a comment
There was a problem hiding this comment.
Thanks for this. The nfpm package CLI path works, and I verified a few things independently so they do not come up again:
866F6C83BAB3E49381ADE4C1BC8ACDD415BD80B3really is the primary key of bothinternal/sign/testdata/privkey_unprotected.ascandtestdata/acceptance/keys/pubkey.asc, and the UID is exactlynfpm test key <test@example.com>, so the acceptance grep matches.- The
.sigdoes reach the Docker build context:acceptance_test.gosetsinfo.Targetbeforepkg.Package, and the build context istestdata/acceptance. - The double
Close()of the tar and zstd writers is safe.archive/tarreturnsnilon the second call via itsErrWriteAfterCloseguard, andklauspost/compress@v1.18.6returnsnilviaerrors.Is(s.err, ErrEncoderClosed). Neither emits extra bytes, including on thefullFrameWrittenpath. TestArchSignaturegenuinely guards the new close ordering: without the explicittw.Close()/zw.Close()the signed bytes are a truncated prefix andPGPVerifyfails.
Requesting changes for two things:
- The packager signs bytes it reads back from
info.Targetinstead of the bytes it wrote tow. In one variant this fails loudly for any library caller; in another it silently signs the wrong bytes. See the comment onarch/arch.go:436. TestArchSignatureErrorpasses for the wrong reason and asserts nothing about key handling. See the comment onarch/arch_test.go:233-242.
The rest are smaller: one real coverage gap in the acceptance stage, a missing binary-vs-armored assertion, stale .sig cleanup, and some nits.
Verification gap, stated plainly: I was asked not to build or run tests locally. Every claim here comes from reading the source, reading the module sources in GOMODCACHE, running gpg against the test keys, and pacman.conf(5). I have not empirically demonstrated that TestArchSignatureError passes for the wrong reason, and I have not shown that any suggested regression test fails without its fix. Please confirm both before acting on them.
Reviewed by an AI agent (GitHub Copilot CLI) at a maintainer's request.
| } | ||
|
|
||
| func createSignature(info *nfpm.Info) ([]byte, error) { | ||
| f, err := os.Open(info.Target) |
There was a problem hiding this comment.
Package signs the bytes it reads back from info.Target, but the contract of Packager.Package(info, w) is that the package goes to w. Nothing ties the two together.
Two reachable outcomes:
1. Target unset — loud failure. Any library caller gets signing error: create signature: open package for signing: open : no such file or directory. goreleaser's nfpm pipe is exactly this shape: it opens its own file and calls packager.Package(info, w) without ever assigning info.Target. Nothing is broken today only because goreleaser's NFPMArchLinux has no signature field yet — but that means this feature is unusable outside the nfpm CLI, and it breaks the moment goreleaser plumbs it through.
2. Target set but not the same bytes as w — silent, and worse. A buffered writer, or a caller that writes to a temp path and renames afterwards, makes os.Open(info.Target) read stale or partial data. The result is a valid PGP signature over bytes that are not the package, with no error and no warning. deb, rpm and apk cannot do this because they sign the bytes they produce.
Target is undocumented (nfpm.go:328, yaml:"-" json:"-", no comment) and assigned only at internal/cmd/package.go:109. This PR turns it into a silent precondition of a signing feature.
Smallest fix: sign what you write. When a signature is configured, wrap the writer once with io.MultiWriter(w, &buf) and sign buf. deb and rpm already buffer the whole package in memory, so this is consistent and costs nothing new. Keep Target only to name the .sig file, and return an explicit error when it is empty rather than letting os.Open("") speak for you.
Test: Default.Package(info, &bytes.Buffer{}) with a valid key file and Target unset, asserting a specific, actionable error.
Correction: an earlier revision of this comment quoted the error with create signature: doubled. On the os.Open path it appears once, as shown above; the doubling happens on the signing-failure path (see the comment on arch/arch.go:442-453).
| func TestArchSignatureError(t *testing.T) { | ||
| info := exampleInfo() | ||
| info.ArchLinux.Signature.KeyFile = "/does/not/exist" | ||
|
|
||
| var pkg bytes.Buffer | ||
| err := Default.Package(info, &pkg) | ||
| require.Error(t, err) | ||
|
|
||
| var expectedError *nfpm.ErrSigningFailure | ||
| require.ErrorAs(t, err, &expectedError) |
There was a problem hiding this comment.
This test passes for the wrong reason.
exampleInfo() never sets Target, and createSignature opens info.Target (arch.go:436) before it ever reads KeyFile (arch.go:451). So the error under test is open : no such file or directory, not anything to do with /does/not/exist.
Two consequences: the test would pass unchanged if you swapped in a valid key file, and it would pass if sign.PGPDetachedSignWithKeyID were deleted outright. It asserts nothing about key handling.
Fix: set info.Target to a real temp file the way the other two tests do, then tighten the assertion so it can only pass for the intended reason:
require.ErrorContains(t, err, "/does/not/exist")Then add the empty-Target case as its own test, per the comment on arch/arch.go:436.
|
|
||
| _, err = f.Seek(0, io.SeekStart) | ||
| require.NoError(t, err) | ||
| err = sign.PGPVerify(f, signature, "../internal/sign/testdata/pubkey.asc") |
There was a problem hiding this comment.
Nothing in the repository asserts that the signature is binary — which is the entire reason PGPDetachedSignWithKeyID was added instead of reusing the existing PGPArmoredDetachSignWithKeyID.
PGPVerify branches on isASCII(signature) (internal/sign/pgp.go:143) and happily accepts either form. TestArchSignatureCallback just below deliberately returns an armored signature and passes. pacman-key --verify shells out to gpg, which also auto-detects armor. So swapping the binary signer for the armored one would pass every test in this repo while emitting a .sig that is not the format pacman expects.
One line closes it:
require.False(t, bytes.HasPrefix(signature, []byte("-----BEGIN")))Minor, same test: line 218 passes info.Target to os.CreateTemp as the name pattern, but info.Target is "" at that point. A literal like "pkg" says what is actually meant.
| RUN pacman-key --init | ||
| RUN pacman-key --add /tmp/pubkey.asc | ||
| RUN pacman-key --lsign-key 866F6C83BAB3E49381ADE4C1BC8ACDD415BD80B3 | ||
| RUN pacman-key --verify /tmp/foo.pkg.tar.zst.sig 2>&1 | grep "gpg: Good signature from \"nfpm test key <test@example.com>\" \[full\]" |
There was a problem hiding this comment.
This stage proves that gpg accepts the signature, but never that pacman does. signed is FROM min, so pacman -U already ran a layer earlier — before the .sig was copied in — and therefore never saw it.
Per pacman.conf(5), the Arch default LocalFileSigLevel = Optional means "signatures are checked if present; absence of a signature is not an error. An invalid signature is a fatal error." So one extra line after the lsign-key gives real end-to-end coverage without touching any config, and it is the only check here that would catch an armored-vs-binary regression at the libalpm level:
RUN pacman --noconfirm -U /tmp/foo.pkg.tar.zst| } | ||
|
|
||
| func createSignatureFile(info *nfpm.Info, sig []byte) error { | ||
| sigFile, err := os.Create(info.Target + ".sig") |
There was a problem hiding this comment.
The .sig is a second output file that nothing ever cleans up.
internal/cmd/package.go:112doesos.Remove(target)whenPackagefails, but neveros.Remove(target + ".sig").- Deterministic footgun: build a signed package, then rebuild the same target with
key_fileremoved. The package is replaced; the old.sigsurvives beside it and now covers different bytes. SinceLocalFileSigLevel = Optionaltreats an invalid signature as fatal,pacman -Urejects the new package with nothing pointing at the cause.
Fix: remove target + ".sig" in the CLI error path, and remove a pre-existing .sig here when no signature is configured.
Test: in internal/cmd, create a .sig, run a package command with signing disabled, assert the .sig is gone.
| func createSignatureFile(info *nfpm.Info, sig []byte) error { | ||
| sigFile, err := os.Create(info.Target + ".sig") | ||
| if err != nil { | ||
| return fmt.Errorf("create signature file: %w", err) | ||
| } | ||
| defer sigFile.Close() | ||
|
|
||
| _, err = sigFile.Write(sig) | ||
| if err != nil { | ||
| return fmt.Errorf("write signature to file: %w", err) | ||
| } | ||
|
|
There was a problem hiding this comment.
This helper is os.WriteFile:
return os.WriteFile(info.Target+".sig", sig, 0o644)One line instead of twelve, an explicit mode rather than whatever umask gives you, and — unlike defer sigFile.Close() — it reports a Close failure instead of discarding it.
| data := bufio.NewReader(f) | ||
|
|
||
| var sig []byte | ||
| if signFn := info.ArchLinux.Signature.SignFn; signFn != nil { | ||
| sig, err = signFn(data) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create signature: %w", err) | ||
| } | ||
| } else { | ||
| sig, err = sign.PGPDetachedSignWithKeyID(data, info.ArchLinux.Signature.KeyFile, info.ArchLinux.Signature.KeyPassphrase, info.ArchLinux.Signature.KeyID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create signature: %w", err) |
There was a problem hiding this comment.
Two nits in this function.
bufio.NewReader(f) costs more than it saves. openpgp.DetachSign copies through io.Copy, which already uses a 32 KiB buffer, so a 4 KiB bufio.Reader only adds a copy and forces smaller reads. Pass f directly.
The "create signature: " prefix is applied here and again by the caller at arch.go:179, so a real failure reads signing error: create signature: create signature: detach sign: reading PGP key file: .... Drop the inner wrap and return the error unchanged — the caller already names the operation.
| archlinuxPassphrase := os.Expand("$NFPM_ARCHLINUX_PASSPHRASE", c.envMappingFunc) | ||
| if archlinuxPassphrase != "" { | ||
| c.ArchLinux.Signature.KeyPassphrase = archlinuxPassphrase | ||
| } |
There was a problem hiding this comment.
nfpm_test.go covers the global passphrase plus the per-format override for deb, rpm and apk, but this new branch has no unit test. One assertion added to the existing table keeps NFPM_ARCHLINUX_PASSPHRASE and its $NFPM_PASSPHRASE fallback from regressing silently.
| key_file: ./internal/sign/testdata/rsa_unprotected.priv | ||
| archlinux: | ||
| signature: | ||
| key_file: ./internal/sign/testdata/privkey_unprotected.asc No newline at end of file |
There was a problem hiding this comment.
Missing trailing newline (\ No newline at end of file in the diff). Worth fixing, especially since this same PR adds one to www/static/schema.json.
| # The package is signed if a key_file is set | ||
| signature: | ||
| # PGP secret key, the passphrase is taken from the environment variable $NFPM_ARCHLINUX_PASSPHRASE with | ||
| # a fallback to $NFPM_PASSPHRASE. | ||
| # This will expand any env var you set in the field, e.g. key_file: ${SIGNING_KEY_FILE} | ||
| key_file: key.gpg | ||
|
|
||
| # PGP secret key id in hex format, if it is not set it will select the first subkey | ||
| # that has the signing flag set. You may need to set this if you want to use the primary key as the signing key. | ||
| # This will expand any env var you set in the field, e.g. key_id: ${ARCHLINUX_SIGNING_KEY_ID} | ||
| key_id: bc8acdd415bd80b3 |
There was a problem hiding this comment.
This block never tells the reader that archlinux signing produces a second file, <package>.sig, or that pacman needs it sitting next to the package. That is the user-visible novelty here — every other format embeds its signature — so it deserves an explicit line.
Worth adding too: a SignFn must return a binary detached signature, not an ASCII-armored one. The SignFn doc comment in nfpm.go now says archlinux receives "the full package content", but says nothing about the expected output format.
Archlinux packages can now be signed with a detached PGP signature, producing a binary .sig file alongside the package — matching the format expected by pacman-key --verify.
The signing reads back the finalized .pkg.tar.zst from disk via info.Target to avoid buffering the entire package in memory. The passphrase is taken from $NFPM_ARCHLINUX_PASSPHRASE with a fallback to $NFPM_PASSPHRASE, consistent with deb/rpm/apk.
Also adds sign.PGPDetachedSignWithKeyID, a streaming variant of PGPSignerWithKeyID that accepts an io.Reader instead of []byte.
See #628