Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
968c365
feat(config): parse the sign directive of stapel images
reyreavman Oct 14, 2026
ffaf63c
feat(signing): produce detached PKCS#7 signatures of files
reyreavman Oct 14, 2026
c8f8ccf
feat(build): require a key for the files the sign directive declares
reyreavman Oct 14, 2026
e750568
feat(build): sign the files the sign directive declares inside the image
reyreavman Oct 14, 2026
adac36b
test(e2e): cover the sign directive and document it
reyreavman Oct 14, 2026
c6ed7c6
fix(build): sign before install too and leave commands without the fl…
reyreavman Oct 14, 2026
f61790c
feat(config): describe the sign directive in the werf.yaml JSON schema
reyreavman Oct 14, 2026
e1df4b3
fix(buildah): resolve the file to sign inside the image root, not the…
reyreavman Oct 15, 2026
6cd727d
fix(build): run the sign stages with network none on every build
reyreavman Oct 16, 2026
80735a9
feat(build): point the files signing key at its own Vault mount and l…
reyreavman Oct 16, 2026
8f35851
fix(build): log the network mode a stage really runs with
reyreavman Oct 16, 2026
f315c8a
fix(build): let the kernel accept the signatures werf writes next to …
reyreavman Oct 22, 2026
c592779
fix(build): sign every declared file with its own signature
reyreavman Oct 22, 2026
cbe04d1
feat(build)!: rename the file signing options to --sign-build-file-*
reyreavman Oct 22, 2026
d6aacb9
fix(build): keep the signing stages offline whatever --backend-networ…
reyreavman Oct 22, 2026
e75094c
feat(build): sign at the end of the image when the directive names no…
reyreavman Oct 22, 2026
38190d8
fix(build): bind the sign stage digest to the signature format
reyreavman Oct 22, 2026
3bb006f
fix(build): isolate file signature artifacts by target platform
alexey-igrychev Oct 22, 2026
11ada48
fix(config): reject overlapping signing inputs and outputs
alexey-igrychev Oct 22, 2026
ed865ef
fix(cli): validate Vault auth for certificate-only signing resolution
alexey-igrychev Oct 22, 2026
8a7a5bc
test(build): guard signing stage ordering and backend options
alexey-igrychev Oct 22, 2026
39b445f
test(build): avoid helper insertion conflict with main
alexey-igrychev Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/werf/build/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func NewCmd(ctx context.Context) *cobra.Command {

common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd)
common.SetupSigningOptions(&commonCmdData, cmd)
common.SetupSignBuildFileOptions(&commonCmdData, cmd)
common.SetupELFSigningOptions(&commonCmdData, cmd)

common.SetupLogOptions(&commonCmdData, cmd)
Expand Down Expand Up @@ -216,7 +217,7 @@ func run(ctx context.Context, containerBackend container_backend.ContainerBacken
return err
}

conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, imagesToProcess, buildOptions)
conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, werfConfig, imagesToProcess, buildOptions)
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/werf/bundle/publish/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func NewCmd(ctx context.Context) *cobra.Command {

common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd)
common.SetupSigningOptions(&commonCmdData, cmd)
common.SetupSignBuildFileOptions(&commonCmdData, cmd)
common.SetupELFSigningOptions(&commonCmdData, cmd)

common.SetupRequireBuiltImages(&commonCmdData, cmd)
Expand Down Expand Up @@ -279,7 +280,7 @@ func runPublish(ctx context.Context, imageNameListFromArgs []string) error {

imagesRepo = storageManager.GetServiceValuesRepo()

conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, imagesToProcess, buildOptions)
conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, werfConfig, imagesToProcess, buildOptions)
if err != nil {
return err
}
Expand Down
9 changes: 9 additions & 0 deletions cmd/werf/common/cmd_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ type CmdData struct {
SignCert *string
SignIntermediates *string

SignBuildFileKey *string
SignBuildFileCert *string
SignBuildFileIntermediates *string
SignBuildFileVaultAddr *string
SignBuildFileVaultTransitPath *string
SignBuildFileVaultAuthJWT *string
SignBuildFileVaultAuthRole *string
SignBuildFileVaultAuthPath *string

VerifyManifest *bool
VerifyRoots *[]string

Expand Down
17 changes: 13 additions & 4 deletions cmd/werf/common/conveyor_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,18 @@ import (
"github.com/werf/werf/v3/pkg/storage"
)

func GetConveyorOptions(ctx context.Context, commonCmdData *CmdData, imagesToProcess config.ImagesToProcess) (build.ConveyorOptions, error) {
func GetConveyorOptions(ctx context.Context, commonCmdData *CmdData, werfConfig *config.WerfConfig, imagesToProcess config.ImagesToProcess) (build.ConveyorOptions, error) {
// The sign directive adds stages to the image, so every command that builds the
// stage chain needs them — resolving an already built image included, or its digest
// would not be the one the build published.
filesSigningOptions, err := getFilesSigningOptions(ctx, commonCmdData, werfConfig, imagesToProcess)
if err != nil {
return build.ConveyorOptions{}, err
}

conveyorOptions := build.ConveyorOptions{
ImagesToProcess: imagesToProcess,
ImagesToProcess: imagesToProcess,
FilesSigningOptions: filesSigningOptions,
}

if len(commonCmdData.GetPlatform()) > 0 {
Expand Down Expand Up @@ -64,8 +73,8 @@ func GetDeferredBuildLog(ctx context.Context, commonCmdData *CmdData) bool {
return requireBuiltImage || !isVerbose
}

func GetConveyorOptionsWithParallel(ctx context.Context, commonCmdData *CmdData, imagesToProcess config.ImagesToProcess, buildStagesOptions build.BuildOptions) (build.ConveyorOptions, error) {
conveyorOptions, err := GetConveyorOptions(ctx, commonCmdData, imagesToProcess)
func GetConveyorOptionsWithParallel(ctx context.Context, commonCmdData *CmdData, werfConfig *config.WerfConfig, imagesToProcess config.ImagesToProcess, buildStagesOptions build.BuildOptions) (build.ConveyorOptions, error) {
conveyorOptions, err := GetConveyorOptions(ctx, commonCmdData, werfConfig, imagesToProcess)
if err != nil {
return conveyorOptions, err
}
Expand Down
157 changes: 157 additions & 0 deletions cmd/werf/common/files_signing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package common

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"math/big"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/samber/lo"

"github.com/werf/werf/v3/pkg/config"
)

var _ = Describe("files signing options", func() {
signingImage := func(name string) config.ImageInterface {
return &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: name, Sign: []*config.Sign{{Path: "/out/x", After: "install"}}}}
}

plainImage := func(name string) config.ImageInterface {
return &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: name}}
}

imageBasedOn := func(name, baseImageName string) config.ImageInterface {
return &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: name, From: baseImageName}}
}

newConfig := func(images ...config.ImageInterface) *config.WerfConfig {
return config.NewWerfConfig(&config.Meta{}, images)
}

allImages := func(werfConfig *config.WerfConfig) config.ImagesToProcess {
imagesToProcess, err := config.NewImagesToProcess(werfConfig, nil, false, false)
Expect(err).NotTo(HaveOccurred())
return imagesToProcess
}

someImages := func(werfConfig *config.WerfConfig, names ...string) config.ImagesToProcess {
imagesToProcess, err := config.NewImagesToProcess(werfConfig, names, false, false)
Expect(err).NotTo(HaveOccurred())
return imagesToProcess
}

withoutImages := func(werfConfig *config.WerfConfig) config.ImagesToProcess {
imagesToProcess, err := config.NewImagesToProcess(werfConfig, nil, false, true)
Expect(err).NotTo(HaveOccurred())
return imagesToProcess
}

certPEM := func() string {
key, err := rsa.GenerateKey(rand.Reader, 2048)
Expect(err).NotTo(HaveOccurred())
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "files-signing-options-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
Expect(err).NotTo(HaveOccurred())
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
}

DescribeTable("refuses an incomplete command line before any stage runs",
func(cmdData *CmdData, expectedError string) {
werfConfig := newConfig(signingImage("app"))
_, err := getFilesSigningOptions(context.Background(), cmdData, werfConfig, allImages(werfConfig))
Expect(err).To(MatchError(ContainSubstring(expectedError)))
},
Entry("no certificate", &CmdData{}, "images app declare files to sign: specify the signing certificate"),
Entry("certificate that cannot be read", &CmdData{SignBuildFileCert: lo.ToPtr("c")}, "read the files signing certificate"),
)

DescribeTable("refuses a vault role or auth path that qualifies no jwt",
func(vaultAuth CmdData) {
werfConfig := newConfig(signingImage("app"))
cert := lo.ToPtr(base64.StdEncoding.EncodeToString([]byte(certPEM())))

for _, keyRef := range []string{"", "k"} {
cmdData := vaultAuth
cmdData.SignBuildFileCert = cert
cmdData.SignBuildFileKey = lo.ToPtr(keyRef)

_, err := getFilesSigningOptions(context.Background(), &cmdData, werfConfig, allImages(werfConfig))
Expect(err).To(MatchError(ContainSubstring("qualify --sign-build-file-vault-auth-jwt")), "--sign-build-file-key %q", keyRef)
}
},
Entry("role", CmdData{SignBuildFileVaultAuthRole: lo.ToPtr("r")}),
Entry("auth path", CmdData{SignBuildFileVaultAuthPath: lo.ToPtr("fox")}),
Entry("role and auth path", CmdData{SignBuildFileVaultAuthRole: lo.ToPtr("r"), SignBuildFileVaultAuthPath: lo.ToPtr("fox")}),
)

It("is disabled when no image being processed declares files to sign", func() {
werfConfig := newConfig(plainImage("app"))

options, err := getFilesSigningOptions(context.Background(), &CmdData{}, werfConfig, allImages(werfConfig))
Expect(err).NotTo(HaveOccurred())
Expect(options.Enabled).To(BeFalse())
})

It("ignores the sign directive of an image that is not being processed", func() {
werfConfig := newConfig(plainImage("plain"), signingImage("signed"))

options, err := getFilesSigningOptions(context.Background(), &CmdData{}, werfConfig, someImages(werfConfig, "plain"))
Expect(err).NotTo(HaveOccurred())
Expect(options.Enabled).To(BeFalse(), "building an unrelated image must not demand the signing material of another one")
})

It("identifies the signature by the certificate without a private key", func() {
werfConfig := newConfig(signingImage("app"))
cert := certPEM()

options, err := getFilesSigningOptions(context.Background(), &CmdData{
SignBuildFileCert: lo.ToPtr(base64.StdEncoding.EncodeToString([]byte(cert))),
}, werfConfig, allImages(werfConfig))
Expect(err).NotTo(HaveOccurred())
Expect(options.Enabled).To(BeTrue())
Expect(options.Cert).To(Equal(cert))
Expect(options.Signer()).To(BeNil(), "no private key was given, so nothing can sign")
})

Describe("the conveyor the command hands the build", func() {
It("signs the files of the images it is about to build", func() {
werfConfig := newConfig(signingImage("signed"), imageBasedOn("app", "signed"), plainImage("unrelated"))
cert := certPEM()
cmdData := &CmdData{SignBuildFileCert: lo.ToPtr(base64.StdEncoding.EncodeToString([]byte(cert)))}

conveyorOptions, err := GetConveyorOptions(context.Background(), cmdData, werfConfig, someImages(werfConfig, "app"))
Expect(err).NotTo(HaveOccurred())
Expect(conveyorOptions.FilesSigningOptions.Enabled).To(BeTrue(), "the image being built is based on a signed one")
Expect(conveyorOptions.FilesSigningOptions.Cert).To(Equal(cert))
Expect(conveyorOptions.FilesSigningOptions.Signer()).To(BeNil())
})

It("signs nothing when the build reaches no image that declares files to sign", func() {
werfConfig := newConfig(signingImage("signed"), plainImage("unrelated"))

for name, imagesToProcess := range map[string]config.ImagesToProcess{
"unrelated image": someImages(werfConfig, "unrelated"),
"no images": withoutImages(werfConfig),
} {
conveyorOptions, err := GetConveyorOptions(context.Background(), &CmdData{}, werfConfig, imagesToProcess)
Expect(err).NotTo(HaveOccurred(), name)
Expect(conveyorOptions.FilesSigningOptions.Enabled).To(BeFalse(), name)
Expect(conveyorOptions.FilesSigningOptions.Cert).To(BeEmpty(), name)
Expect(conveyorOptions.FilesSigningOptions.Signer()).To(BeNil(), name)
}
})
})
})
97 changes: 97 additions & 0 deletions cmd/werf/common/signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/werf/common-go/pkg/util"
"github.com/werf/werf/v3/pkg/build/signing"
"github.com/werf/werf/v3/pkg/config"
"github.com/werf/werf/v3/pkg/signature"
"github.com/werf/werf/v3/pkg/util/option"
"github.com/werf/werf/v3/pkg/werf/exec"
Expand Down Expand Up @@ -45,6 +46,40 @@ func GetSignManifest(cmdData *CmdData) bool {
return option.PtrValueOrDefault(cmdData.SignManifest, false)
}

func SetupSignBuildFileOptions(cmdData *CmdData, cmd *cobra.Command) {
cmdData.SignBuildFileKey = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileKey, "sign-build-file-key", "", os.Getenv("WERF_SIGN_BUILD_FILE_KEY"),
"The private key to sign files with as path to PEM file, base64-encoded PEM or hashivault://[KEY] (default $WERF_SIGN_BUILD_FILE_KEY)")

cmdData.SignBuildFileCert = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileCert, "sign-build-file-cert", "", os.Getenv("WERF_SIGN_BUILD_FILE_CERT"),
"The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM (default $WERF_SIGN_BUILD_FILE_CERT)")

cmdData.SignBuildFileIntermediates = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileIntermediates, "sign-build-file-intermediates", "", os.Getenv("WERF_SIGN_BUILD_FILE_INTERMEDIATES"),
"The intermediate certificates of the files signing key as path to PEM file or base64-encoded PEM (default $WERF_SIGN_BUILD_FILE_INTERMEDIATES)")

cmdData.SignBuildFileVaultAddr = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileVaultAddr, "sign-build-file-vault-addr", "", os.Getenv("WERF_SIGN_BUILD_FILE_VAULT_ADDR"),
"The Vault address a hashivault:// files signing key is resolved against (default $WERF_SIGN_BUILD_FILE_VAULT_ADDR, then $VAULT_ADDR)")

cmdData.SignBuildFileVaultTransitPath = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileVaultTransitPath, "sign-build-file-vault-transit-path", "", os.Getenv("WERF_SIGN_BUILD_FILE_VAULT_TRANSIT_PATH"),
"The Vault transit engine mount of the files signing key (default $WERF_SIGN_BUILD_FILE_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH)")

cmdData.SignBuildFileVaultAuthJWT = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileVaultAuthJWT, "sign-build-file-vault-auth-jwt", "", os.Getenv("WERF_SIGN_BUILD_FILE_VAULT_AUTH_JWT"),
"The JWT to log in to Vault with for the files signing key; when unset the Vault authentication is taken from the environment, as for --sign-key (default $WERF_SIGN_BUILD_FILE_VAULT_AUTH_JWT)")

cmdData.SignBuildFileVaultAuthRole = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileVaultAuthRole, "sign-build-file-vault-auth-role", "", os.Getenv("WERF_SIGN_BUILD_FILE_VAULT_AUTH_ROLE"),
"The Vault JWT role for --sign-build-file-vault-auth-jwt (default $WERF_SIGN_BUILD_FILE_VAULT_AUTH_ROLE)")

cmdData.SignBuildFileVaultAuthPath = new(string)
cmd.Flags().StringVarP(cmdData.SignBuildFileVaultAuthPath, "sign-build-file-vault-auth-path", "", os.Getenv("WERF_SIGN_BUILD_FILE_VAULT_AUTH_PATH"),
"The Vault JWT auth mount for --sign-build-file-vault-auth-jwt (default $WERF_SIGN_BUILD_FILE_VAULT_AUTH_PATH, then jwt)")
}

func SetupELFSigningOptions(cmdData *CmdData, cmd *cobra.Command) {
cmdData.SignELFFiles = new(bool)
cmd.Flags().BoolVarP(cmdData.SignELFFiles, "sign-elf-files", "", util.GetBoolEnvironmentDefaultFalse("WERF_SIGN_ELF_FILES"),
Expand Down Expand Up @@ -154,6 +189,68 @@ func getVexSigningOptions(commonCmdData *CmdData, signer *signing.Signer) signin
return options
}

// getFilesSigningOptions builds the options of the sign directive for the images that
// are about to be processed. The certificate is the public identity of the signature
// and takes part in the stage digest, so it is required as soon as one of those images
// declares the directive. The private key only produces the signature: it stays
// optional here, and a command that resolves an already built image never asks for it.
func getFilesSigningOptions(ctx context.Context, commonCmdData *CmdData, werfConfig *config.WerfConfig, imagesToProcess config.ImagesToProcess) (signing.FilesSigningOptions, error) {
var signingImages []string
for _, img := range werfConfig.GetImagesForProcessing(imagesToProcess) {
stapelImg, ok := img.(config.StapelImageInterface)
if !ok || len(stapelImg.ImageBaseConfig().Sign) == 0 {
continue
}
signingImages = append(signingImages, img.GetName())
}

if len(signingImages) == 0 {
return signing.FilesSigningOptions{}, nil
}

certRef := lo.FromPtr(commonCmdData.SignBuildFileCert)
if certRef == "" {
return signing.FilesSigningOptions{}, fmt.Errorf("images %s declare files to sign: specify the signing certificate with --sign-build-file-cert", strings.Join(signingImages, ", "))
}

cert, err := signing.ReadCertificatePEM(certRef)
if err != nil {
return signing.FilesSigningOptions{}, fmt.Errorf("read the files signing certificate: %w", err)
}

vault := signing.VaultOptions{
Address: lo.FromPtr(commonCmdData.SignBuildFileVaultAddr),
TransitPath: lo.FromPtr(commonCmdData.SignBuildFileVaultTransitPath),
AuthJWT: lo.FromPtr(commonCmdData.SignBuildFileVaultAuthJWT),
AuthRole: lo.FromPtr(commonCmdData.SignBuildFileVaultAuthRole),
AuthPath: lo.FromPtr(commonCmdData.SignBuildFileVaultAuthPath),
}
if (vault.AuthRole != "" || vault.AuthPath != "") && vault.AuthJWT == "" {
return signing.FilesSigningOptions{}, fmt.Errorf("--sign-build-file-vault-auth-role and --sign-build-file-vault-auth-path qualify --sign-build-file-vault-auth-jwt: specify the JWT as well, or drop them to log in to Vault as --sign-key does")
}

keyRef := lo.FromPtr(commonCmdData.SignBuildFileKey)
if keyRef == "" {
options := signing.NewFilesSigningOptions(cert, nil)
options.Enabled = true
return options, nil
}

signer, err := signing.NewSigner(ctx, signing.SignerOptions{
KeyRef: keyRef,
CertRef: certRef,
IntermediatesRef: lo.FromPtr(commonCmdData.SignBuildFileIntermediates),
Vault: vault,
})
if err != nil {
return signing.FilesSigningOptions{}, fmt.Errorf("create files signer: %w", err)
}

options := signing.NewFilesSigningOptions(cert, signer)
options.Enabled = true
return options, nil
}

func getELFSigningOptions(commonCmdData *CmdData, signer *signing.Signer) (signing.ELFSigningOptions, error) {
options := signing.NewELFSigningOptions(signer)

Expand Down
3 changes: 2 additions & 1 deletion cmd/werf/compose/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ func newCmd(ctx context.Context, composeCmdName string, options *newCmdOptions)

common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd)
common.SetupSigningOptions(&commonCmdData, cmd)
common.SetupSignBuildFileOptions(&commonCmdData, cmd)
common.SetupELFSigningOptions(&commonCmdData, cmd)

common.SetupRequireBuiltImages(&commonCmdData, cmd)
Expand Down Expand Up @@ -474,7 +475,7 @@ func run(ctx context.Context, containerBackend container_backend.ContainerBacken

logboek.Context(ctx).Default().LogOptionalLn()

conveyorOptions, err := common.GetConveyorOptions(ctx, &commonCmdData, imagesToProcess)
conveyorOptions, err := common.GetConveyorOptions(ctx, &commonCmdData, werfConfig, imagesToProcess)
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/werf/converge/converge.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ werf converge --repo registry.mydomain.com/web --env production`,

common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd)
common.SetupSigningOptions(&commonCmdData, cmd)
common.SetupSignBuildFileOptions(&commonCmdData, cmd)
common.SetupELFSigningOptions(&commonCmdData, cmd)

common.SetupSecondaryStagesStorageOptions(&commonCmdData, cmd)
Expand Down Expand Up @@ -323,7 +324,7 @@ func run(

imagesRepo = storageManager.GetServiceValuesRepo()

conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, imagesToProcess, buildOptions)
conveyorOptions, err := common.GetConveyorOptionsWithParallel(ctx, &commonCmdData, werfConfig, imagesToProcess, buildOptions)
if err != nil {
return ctx, err
}
Expand Down
Loading
Loading