diff --git a/cmd/werf/build/main.go b/cmd/werf/build/main.go index 614fb08c79..60e2554d18 100644 --- a/cmd/werf/build/main.go +++ b/cmd/werf/build/main.go @@ -89,6 +89,7 @@ func NewCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupLogOptions(&commonCmdData, cmd) diff --git a/cmd/werf/bundle/publish/publish.go b/cmd/werf/bundle/publish/publish.go index ffa3f7f6ee..105c6479bd 100644 --- a/cmd/werf/bundle/publish/publish.go +++ b/cmd/werf/bundle/publish/publish.go @@ -136,6 +136,7 @@ func NewCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) diff --git a/cmd/werf/common/cmd_data.go b/cmd/werf/common/cmd_data.go index 3985b6386c..08212c9076 100644 --- a/cmd/werf/common/cmd_data.go +++ b/cmd/werf/common/cmd_data.go @@ -123,6 +123,16 @@ type CmdData struct { SignCert *string SignIntermediates *string + SignFiles *bool + SignFilesKey *string + SignFilesCert *string + SignFilesIntermediates *string + SignFilesVaultAddr *string + SignFilesVaultTransitPath *string + SignFilesVaultAuthJWT *string + SignFilesVaultAuthRole *string + SignFilesVaultAuthPath *string + VerifyManifest *bool VerifyRoots *[]string diff --git a/cmd/werf/common/conveyor_options.go b/cmd/werf/common/conveyor_options.go index 98fc8c403a..78f3264717 100644 --- a/cmd/werf/common/conveyor_options.go +++ b/cmd/werf/common/conveyor_options.go @@ -75,6 +75,7 @@ func GetConveyorOptionsWithParallel(ctx context.Context, commonCmdData *CmdData, conveyorOptions.ManifestSigningOptions = buildStagesOptions.ManifestSigningOptions conveyorOptions.SbomSigningOptions = buildStagesOptions.SbomSigningOptions conveyorOptions.VexSigningOptions = buildStagesOptions.VexSigningOptions + conveyorOptions.FilesSigningOptions = buildStagesOptions.FilesSigningOptions conveyorOptions.VerityAnnotationOptions = buildStagesOptions.VerityAnnotationOptions return conveyorOptions, nil @@ -138,6 +139,11 @@ func GetBuildOptions(ctx context.Context, commonCmdData *CmdData, werfConfig *co return buildOptions, err } + filesSigningOptions, err := getFilesSigningOptions(ctx, commonCmdData, werfConfig) + if err != nil { + return buildOptions, err + } + verityAnnotationOptions, err := getVerityAnnotationOptions(commonCmdData) if err != nil { return build.BuildOptions{}, fmt.Errorf("getting verity annotation options: %w", err) @@ -157,6 +163,7 @@ func GetBuildOptions(ctx context.Context, commonCmdData *CmdData, werfConfig *co SbomSigningOptions: sbomSigningOptions, VexSigningOptions: vexSigningOptions, ELFSigningOptions: elfSigningOptions, + FilesSigningOptions: filesSigningOptions, VerityAnnotationOptions: verityAnnotationOptions, ReportOperations: GetBuildReportOperations(commonCmdData), } diff --git a/cmd/werf/common/files_signing_test.go b/cmd/werf/common/files_signing_test.go new file mode 100644 index 0000000000..6310cba83e --- /dev/null +++ b/cmd/werf/common/files_signing_test.go @@ -0,0 +1,43 @@ +package common + +import ( + "context" + + . "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() { + signingConfig := func() *config.WerfConfig { + return config.NewWerfConfig(&config.Meta{}, []config.ImageInterface{ + &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: "app", Sign: []*config.Sign{{Path: "/out/x", After: "install"}}}}, + }) + } + + DescribeTable("refuses an incomplete command line before any stage runs", + func(cmdData *CmdData, expectedError string) { + _, err := getFilesSigningOptions(context.Background(), cmdData, signingConfig()) + Expect(err).To(MatchError(ContainSubstring(expectedError))) + }, + Entry("no key", &CmdData{SignFiles: lo.ToPtr(true)}, "images app declare files to sign: specify the signing key"), + Entry("no certificate", &CmdData{SignFiles: lo.ToPtr(true), SignFilesKey: lo.ToPtr("k")}, "specify the signing certificate"), + Entry("vault role without jwt", &CmdData{SignFiles: lo.ToPtr(true), SignFilesKey: lo.ToPtr("k"), SignFilesCert: lo.ToPtr("c"), SignFilesVaultAuthRole: lo.ToPtr("r")}, "qualify --sign-files-vault-auth-jwt"), + Entry("vault auth path without jwt", &CmdData{SignFiles: lo.ToPtr(true), SignFilesKey: lo.ToPtr("k"), SignFilesCert: lo.ToPtr("c"), SignFilesVaultAuthPath: lo.ToPtr("fox")}, "qualify --sign-files-vault-auth-jwt"), + ) + + DescribeTable("is disabled without touching the key", + func(cmdData *CmdData, werfConfig *config.WerfConfig) { + options, err := getFilesSigningOptions(context.Background(), cmdData, werfConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(options.Enabled).To(BeFalse()) + }, + Entry("signing switched off", &CmdData{SignFiles: lo.ToPtr(false)}, signingConfig()), + Entry("flag not registered", &CmdData{}, signingConfig()), + Entry("no image declares files to sign", &CmdData{SignFiles: lo.ToPtr(true)}, config.NewWerfConfig(&config.Meta{}, []config.ImageInterface{ + &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: "app"}}, + })), + ) +}) diff --git a/cmd/werf/common/signature.go b/cmd/werf/common/signature.go index 9af0d1c1ee..c416bd8c8b 100644 --- a/cmd/werf/common/signature.go +++ b/cmd/werf/common/signature.go @@ -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" @@ -45,6 +46,52 @@ func GetSignManifest(cmdData *CmdData) bool { return option.PtrValueOrDefault(cmdData.SignManifest, false) } +func SetupFilesSigningOptions(cmdData *CmdData, cmd *cobra.Command) { + cmdData.SignFiles = new(bool) + cmd.Flags().BoolVarP(cmdData.SignFiles, "sign-files", "", util.GetBoolEnvironmentDefaultTrue("WERF_SIGN_FILES"), + `Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES or true). +When enabled and at least one image declares files to sign, +the private signing key must be specified with --sign-files-key option and +the certificate must be specified with --sign-files-cert option. +Disable to build such images without signatures`) + + cmdData.SignFilesKey = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesKey, "sign-files-key", "", os.Getenv("WERF_SIGN_FILES_KEY"), + "The private key to sign files with as path to PEM file, base64-encoded PEM or hashivault://[KEY] (default $WERF_SIGN_FILES_KEY)") + + cmdData.SignFilesCert = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesCert, "sign-files-cert", "", os.Getenv("WERF_SIGN_FILES_CERT"), + "The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM (default $WERF_SIGN_FILES_CERT)") + + cmdData.SignFilesIntermediates = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesIntermediates, "sign-files-intermediates", "", os.Getenv("WERF_SIGN_FILES_INTERMEDIATES"), + "The intermediate certificates of the files signing key as path to PEM file or base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES)") + + cmdData.SignFilesVaultAddr = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesVaultAddr, "sign-files-vault-addr", "", os.Getenv("WERF_SIGN_FILES_VAULT_ADDR"), + "The Vault address a hashivault:// files signing key is resolved against (default $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR)") + + cmdData.SignFilesVaultTransitPath = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesVaultTransitPath, "sign-files-vault-transit-path", "", os.Getenv("WERF_SIGN_FILES_VAULT_TRANSIT_PATH"), + "The Vault transit engine mount of the files signing key (default $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH)") + + cmdData.SignFilesVaultAuthJWT = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesVaultAuthJWT, "sign-files-vault-auth-jwt", "", os.Getenv("WERF_SIGN_FILES_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_FILES_VAULT_AUTH_JWT)") + + cmdData.SignFilesVaultAuthRole = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesVaultAuthRole, "sign-files-vault-auth-role", "", os.Getenv("WERF_SIGN_FILES_VAULT_AUTH_ROLE"), + "The Vault JWT role for --sign-files-vault-auth-jwt (default $WERF_SIGN_FILES_VAULT_AUTH_ROLE)") + + cmdData.SignFilesVaultAuthPath = new(string) + cmd.Flags().StringVarP(cmdData.SignFilesVaultAuthPath, "sign-files-vault-auth-path", "", os.Getenv("WERF_SIGN_FILES_VAULT_AUTH_PATH"), + "The Vault JWT auth mount for --sign-files-vault-auth-jwt (default $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt)") +} + +func GetSignFiles(cmdData *CmdData) bool { + return option.PtrValueOrDefault(cmdData.SignFiles, false) +} + 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"), @@ -154,6 +201,58 @@ func getVexSigningOptions(commonCmdData *CmdData, signer *signing.Signer) signin return options } +// getFilesSigningOptions builds the signer of the sign directive. The directive is a +// requirement of the image, so when an image declares one the key and certificate are +// mandatory unless signing is switched off explicitly. +func getFilesSigningOptions(ctx context.Context, commonCmdData *CmdData, werfConfig *config.WerfConfig) (signing.FilesSigningOptions, error) { + var signingImages []string + for _, img := range werfConfig.Images(false) { + stapelImg, ok := img.(config.StapelImageInterface) + if !ok || len(stapelImg.ImageBaseConfig().Sign) == 0 { + continue + } + signingImages = append(signingImages, img.GetName()) + } + + if len(signingImages) == 0 || !GetSignFiles(commonCmdData) { + return signing.NewFilesSigningOptions(&signing.Signer{}), nil + } + + keyRef := lo.FromPtr(commonCmdData.SignFilesKey) + certRef := lo.FromPtr(commonCmdData.SignFilesCert) + switch { + case keyRef == "": + return signing.FilesSigningOptions{}, fmt.Errorf("images %s declare files to sign: specify the signing key with --sign-files-key or pass --sign-files=false to build them unsigned", strings.Join(signingImages, ", ")) + case certRef == "": + return signing.FilesSigningOptions{}, fmt.Errorf("images %s declare files to sign: specify the signing certificate with --sign-files-cert", strings.Join(signingImages, ", ")) + } + + vault := signing.VaultOptions{ + Address: lo.FromPtr(commonCmdData.SignFilesVaultAddr), + TransitPath: lo.FromPtr(commonCmdData.SignFilesVaultTransitPath), + AuthJWT: lo.FromPtr(commonCmdData.SignFilesVaultAuthJWT), + AuthRole: lo.FromPtr(commonCmdData.SignFilesVaultAuthRole), + AuthPath: lo.FromPtr(commonCmdData.SignFilesVaultAuthPath), + } + if (vault.AuthRole != "" || vault.AuthPath != "") && vault.AuthJWT == "" { + return signing.FilesSigningOptions{}, fmt.Errorf("--sign-files-vault-auth-role and --sign-files-vault-auth-path qualify --sign-files-vault-auth-jwt: specify the JWT as well, or drop them to log in to Vault as --sign-key does") + } + + signer, err := signing.NewSigner(ctx, signing.SignerOptions{ + KeyRef: keyRef, + CertRef: certRef, + IntermediatesRef: lo.FromPtr(commonCmdData.SignFilesIntermediates), + Vault: vault, + }) + if err != nil { + return signing.FilesSigningOptions{}, fmt.Errorf("create files signer: %w", err) + } + + options := signing.NewFilesSigningOptions(signer) + options.Enabled = true + return options, nil +} + func getELFSigningOptions(commonCmdData *CmdData, signer *signing.Signer) (signing.ELFSigningOptions, error) { options := signing.NewELFSigningOptions(signer) diff --git a/cmd/werf/compose/main.go b/cmd/werf/compose/main.go index 52b7240412..f94020b0b4 100644 --- a/cmd/werf/compose/main.go +++ b/cmd/werf/compose/main.go @@ -303,6 +303,7 @@ func newCmd(ctx context.Context, composeCmdName string, options *newCmdOptions) common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) diff --git a/cmd/werf/converge/converge.go b/cmd/werf/converge/converge.go index 3416dda8f2..ca17f72f41 100644 --- a/cmd/werf/converge/converge.go +++ b/cmd/werf/converge/converge.go @@ -92,6 +92,7 @@ werf converge --repo registry.mydomain.com/web --env production`, common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupSecondaryStagesStorageOptions(&commonCmdData, cmd) diff --git a/cmd/werf/export/export.go b/cmd/werf/export/export.go index b8d90046ca..c4c2be7433 100644 --- a/cmd/werf/export/export.go +++ b/cmd/werf/export/export.go @@ -109,6 +109,7 @@ func NewExportCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) diff --git a/cmd/werf/kube_run/kube_run.go b/cmd/werf/kube_run/kube_run.go index 77bc77c4c2..bfe33294a5 100644 --- a/cmd/werf/kube_run/kube_run.go +++ b/cmd/werf/kube_run/kube_run.go @@ -171,6 +171,7 @@ func NewCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) diff --git a/cmd/werf/plan/plan.go b/cmd/werf/plan/plan.go index 0c49d2abd0..319459d255 100644 --- a/cmd/werf/plan/plan.go +++ b/cmd/werf/plan/plan.go @@ -130,6 +130,7 @@ werf plan --repo registry.mydomain.com/web --env production`, common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupDisableAutoHostCleanup(&commonCmdData, cmd) diff --git a/cmd/werf/render/render.go b/cmd/werf/render/render.go index e6ad72bd8e..b54a7e5fb4 100644 --- a/cmd/werf/render/render.go +++ b/cmd/werf/render/render.go @@ -121,6 +121,7 @@ func NewCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) commonCmdData.SetupPlatform(cmd) diff --git a/cmd/werf/run/run.go b/cmd/werf/run/run.go index 0fba9d6b67..432e1ae597 100644 --- a/cmd/werf/run/run.go +++ b/cmd/werf/run/run.go @@ -137,6 +137,7 @@ func NewCmd(ctx context.Context) *cobra.Command { common.SetupAnnotateLayersWithDmVerityRootHash(&commonCmdData, cmd) common.SetupSigningOptions(&commonCmdData, cmd) + common.SetupFilesSigningOptions(&commonCmdData, cmd) common.SetupELFSigningOptions(&commonCmdData, cmd) common.SetupRequireBuiltImages(&commonCmdData, cmd) diff --git a/docs/_data/werf_yaml.yml b/docs/_data/werf_yaml.yml index 03f3c742a6..d9243cad7e 100644 --- a/docs/_data/werf_yaml.yml +++ b/docs/_data/werf_yaml.yml @@ -928,6 +928,31 @@ sections: description: en: "Globs for excluding" ru: "Глобы исключения" + - name: sign + description: + en: "Files to sign inside the image" + ru: "Подписание файлов внутри образа" + detailsArticle: + en: "/usage/build/stapel/imports.html#signing-files-inside-the-image" + ru: "/usage/build/stapel/imports.html#подписание-файлов-внутри-образа" + collapsible: true + isCollapsedByDefault: false + directiveList: + - name: path + value: "string" + description: + en: "The absolute path of the file to sign; the detached PKCS#7 signature is written next to it as `.p7s`" + ru: "Абсолютный путь к подписываемому файлу; отсоединённая подпись PKCS#7 записывается рядом как `.p7s`" + - name: before + value: "string" + description: + en: "The stage name before which to sign the file. At present, only install and setup stages are supported" + ru: "Стадия, перед которой подписать файл. Поддерживаются только стадии install и setup" + - name: after + value: "string" + description: + en: "The stage name after which to sign the file. At present, only install and setup stages are supported" + ru: "Стадия, после которой подписать файл. Поддерживаются только стадии install и setup" - name: dependencies description: en: "Dependencies images for current image" diff --git a/docs/_includes/reference/cli/werf_build.md b/docs/_includes/reference/cli/werf_build.md index 040d5c622a..9c32190e24 100644 --- a/docs/_includes/reference/cli/werf_build.md +++ b/docs/_includes/reference/cli/werf_build.md @@ -280,6 +280,38 @@ werf build [IMAGE_NAME...] [options] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_bundle_publish.md b/docs/_includes/reference/cli/werf_bundle_publish.md index 94b5941445..3b8461987a 100644 --- a/docs/_includes/reference/cli/werf_bundle_publish.md +++ b/docs/_includes/reference/cli/werf_bundle_publish.md @@ -328,6 +328,38 @@ werf bundle publish [IMAGE_NAME...] [options] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_compose_config.md b/docs/_includes/reference/cli/werf_compose_config.md index cdcb48de1b..75b1f39ab6 100644 --- a/docs/_includes/reference/cli/werf_compose_config.md +++ b/docs/_includes/reference/cli/werf_compose_config.md @@ -272,6 +272,38 @@ werf compose config [IMAGE_NAME...] [options] [--docker-compose-options="OPTIONS Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_compose_down.md b/docs/_includes/reference/cli/werf_compose_down.md index 1db89e6622..946da9edb6 100644 --- a/docs/_includes/reference/cli/werf_compose_down.md +++ b/docs/_includes/reference/cli/werf_compose_down.md @@ -265,6 +265,38 @@ werf compose down [IMAGE_NAME...] [options] [--docker-compose-options="OPTIONS"] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_compose_run.md b/docs/_includes/reference/cli/werf_compose_run.md index 6532501981..ecd19fda0e 100644 --- a/docs/_includes/reference/cli/werf_compose_run.md +++ b/docs/_includes/reference/cli/werf_compose_run.md @@ -262,6 +262,38 @@ werf compose run [IMAGE_NAME...] [options] [--docker-compose-options="OPTIONS"] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_compose_up.md b/docs/_includes/reference/cli/werf_compose_up.md index 255730b56b..0bf1cb3056 100644 --- a/docs/_includes/reference/cli/werf_compose_up.md +++ b/docs/_includes/reference/cli/werf_compose_up.md @@ -273,6 +273,38 @@ werf compose up [IMAGE_NAME...] [options] [--docker-compose-options="OPTIONS"] [ Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_converge.md b/docs/_includes/reference/cli/werf_converge.md index 964feac08f..f91478f3df 100644 --- a/docs/_includes/reference/cli/werf_converge.md +++ b/docs/_includes/reference/cli/werf_converge.md @@ -521,6 +521,38 @@ werf converge --repo registry.mydomain.com/web --env production Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_export.md b/docs/_includes/reference/cli/werf_export.md index cd29c7cc50..7d75b449a3 100644 --- a/docs/_includes/reference/cli/werf_export.md +++ b/docs/_includes/reference/cli/werf_export.md @@ -242,6 +242,38 @@ werf export [IMAGE_NAME...] [options] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_kube_run.md b/docs/_includes/reference/cli/werf_kube_run.md index 2ae44f27fa..a14b11d5e4 100644 --- a/docs/_includes/reference/cli/werf_kube_run.md +++ b/docs/_includes/reference/cli/werf_kube_run.md @@ -309,6 +309,38 @@ werf kube-run [options] [IMAGE_NAME] [-- COMMAND ARG...] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_plan.md b/docs/_includes/reference/cli/werf_plan.md index 696acdec30..f4a3c1793f 100644 --- a/docs/_includes/reference/cli/werf_plan.md +++ b/docs/_includes/reference/cli/werf_plan.md @@ -506,6 +506,38 @@ werf plan --repo registry.mydomain.com/web --env production Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_render.md b/docs/_includes/reference/cli/werf_render.md index afd827729b..ef267827b8 100644 --- a/docs/_includes/reference/cli/werf_render.md +++ b/docs/_includes/reference/cli/werf_render.md @@ -407,6 +407,38 @@ werf render [IMAGE_NAME...] [options] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/_includes/reference/cli/werf_run.md b/docs/_includes/reference/cli/werf_run.md index 5f3b3244f5..7a6cf955fa 100644 --- a/docs/_includes/reference/cli/werf_run.md +++ b/docs/_includes/reference/cli/werf_run.md @@ -215,6 +215,38 @@ werf run [options] [IMAGE_NAME] [-- COMMAND ARG...] Enable ELF files signing (default $WERF_SIGN_ELF_FILES). When enabled, the private signing key must be specified with --sign-key option and the certificate must be specified with --sign-cert option + --sign-files=true + Sign the files declared with the sign directive of werf.yaml (default $WERF_SIGN_FILES + or true). + When enabled and at least one image declares files to sign, + the private signing key must be specified with --sign-files-key option and + the certificate must be specified with --sign-files-cert option. + Disable to build such images without signatures + --sign-files-cert="" + The leaf certificate of the files signing key as path to PEM file or base64-encoded PEM + (default $WERF_SIGN_FILES_CERT) + --sign-files-intermediates="" + The intermediate certificates of the files signing key as path to PEM file or + base64-encoded PEM (default $WERF_SIGN_FILES_INTERMEDIATES) + --sign-files-key="" + The private key to sign files with as path to PEM file, base64-encoded PEM or + hashivault://[KEY] (default $WERF_SIGN_FILES_KEY) + --sign-files-vault-addr="" + The Vault address a hashivault:// files signing key is resolved against (default + $WERF_SIGN_FILES_VAULT_ADDR, then $VAULT_ADDR) + --sign-files-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_FILES_VAULT_AUTH_JWT) + --sign-files-vault-auth-path="" + The Vault JWT auth mount for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_PATH, then jwt) + --sign-files-vault-auth-role="" + The Vault JWT role for --sign-files-vault-auth-jwt (default + $WERF_SIGN_FILES_VAULT_AUTH_ROLE) + --sign-files-vault-transit-path="" + The Vault transit engine mount of the files signing key (default + $WERF_SIGN_FILES_VAULT_TRANSIT_PATH, then $TRANSIT_SECRET_ENGINE_PATH) --sign-intermediates="" The intermediate certificates as path to PEM file or base64-encoded PEM (default $WERF_SIGN_INTERMEDIATES) diff --git a/docs/pages_en/usage/build/sbom.md b/docs/pages_en/usage/build/sbom.md index f1d1a4df49..bb79201edf 100644 --- a/docs/pages_en/usage/build/sbom.md +++ b/docs/pages_en/usage/build/sbom.md @@ -17,7 +17,7 @@ SBOM generation requires a container registry (specify --repo) - Only the **Docker backend** is supported. - Only the **Stapel syntax** for describing images (`werf.yaml`) is supported. -- **Network is disabled in shell stages**: downloading dependencies with a command in any shell stage (`beforeInstall`, `install`, `beforeSetup`, `setup`) will not work — dependencies are installed declaratively via the [`packages` directive]({{ "/usage/build/stapel/instructions.html#installing-binary-packages" | true_relative_url }}) only. This is what guarantees that all dependencies are recorded in the SBOM. +- **Network is disabled in shell stages**: downloading dependencies with a command in any shell stage (`beforeInstall`, `install`, `beforeSetup`, `setup`) will not work — dependencies are installed declaratively via the [`packages` directive]({{ "/usage/build/stapel/instructions.html#installing-binary-packages" | true_relative_url }}) only. This is what guarantees that all dependencies are recorded in the SBOM. A result that has to be signed by an external KMS is produced with the [`sign` directive]({{ "/usage/build/stapel/imports.html#signing-files-inside-the-image" | true_relative_url }}), which needs no network inside the stage. - The SBOM is built from **controlled inputs**: every `packages` entry is handled by its own cataloger (for file-based types syft reads the manifest/lock file — `go.sum`, `package-lock.json`, and so on; for `os-pm` — a dedicated cataloger). The list of supported ecosystems is fixed. - **Vendored dependencies are not recorded**: dependencies committed to the repository directly (`vendor/`, `third_party/`, and so on) bypass `packages` and do not end up in the SBOM. Do not use vendoring — all dependencies must come in via `packages`. - An SBOM exists only for images built with `build.sbom.enable: true` — previously built images have no SBOM and must be rebuilt. diff --git a/docs/pages_en/usage/build/stapel/imports.md b/docs/pages_en/usage/build/stapel/imports.md index 33f7fec9b1..d44ee99595 100644 --- a/docs/pages_en/usage/build/stapel/imports.md +++ b/docs/pages_en/usage/build/stapel/imports.md @@ -94,3 +94,43 @@ If `add` is a **file**, the outcome depends on the _destination image_: werf creates only the parent directory of `to`, so whether `to` itself exists is decided by the _destination image_ — pin the full file path when the difference matters. A trailing slash in `to` (anything other than `to: /`) is a configuration error and fails the build: write `to: /usr/sbin`, not `to: /usr/sbin/`. + +## Signing files inside the image + +The `sign` directive signs a file the image produces and lays the signature down next to it, without giving the build stage access to the signing key. It fits the same place in the stage chain as `import`: the file must exist after the stage named by `before` or `after`, and the signature is in place for the stages that follow. + +```yaml +image: sysext +from: registry.example.com/base/ubuntu:22.04 +shell: + install: + - mkfs.erofs /out/ext.raw /src + - veritysetup format --root-hash-file=/out/ext.roothash /out/ext.raw /out/ext.verity +sign: +- path: /out/ext.roothash + after: install +``` + +werf reads the file out of the image built so far, signs it on the build host and adds `.p7s` as a new layer. The signature is a detached PKCS#7 (CMS) `SignedData` over the file content — SHA-256, RSASSA-PKCS1-v1_5, no authenticated attributes, the signing certificate embedded — the same shape `systemd-keyutil pkcs7` produces, so systemd and the Linux kernel accept it as a dm-verity root hash signature. The key therefore has to be RSA. + +The key and the certificate are never part of `werf.yaml`; they come from the command line only, separately from the manifest and SBOM signing identity: + +| Flag | Environment variable | Description | +|---|---|---| +| `--sign-files` | `WERF_SIGN_FILES` | process `sign` directives (default `true`); `false` skips them and builds the image without the signatures | +| `--sign-files-key` | `WERF_SIGN_FILES_KEY` | the private key: a path to a PEM file, a base64-encoded PEM, or `hashivault://[KEY]` | +| `--sign-files-cert` | `WERF_SIGN_FILES_CERT` | the signing certificate: a path to a PEM file or a base64-encoded PEM | +| `--sign-files-intermediates` | `WERF_SIGN_FILES_INTERMEDIATES` | the intermediate certificates: a path to a PEM file or a base64-encoded PEM | +| `--sign-files-vault-addr` | `WERF_SIGN_FILES_VAULT_ADDR` | the Vault address a `hashivault://` key is resolved against; unset → `VAULT_ADDR` | +| `--sign-files-vault-transit-path` | `WERF_SIGN_FILES_VAULT_TRANSIT_PATH` | the transit engine mount of the key; unset → `TRANSIT_SECRET_ENGINE_PATH` | +| `--sign-files-vault-auth-jwt` | `WERF_SIGN_FILES_VAULT_AUTH_JWT` | the JWT to log in to Vault with; unset → the authentication `--sign-key` uses, from the environment | +| `--sign-files-vault-auth-role` | `WERF_SIGN_FILES_VAULT_AUTH_ROLE` | the Vault JWT role for that JWT | +| `--sign-files-vault-auth-path` | `WERF_SIGN_FILES_VAULT_AUTH_PATH` | the JWT auth mount for that JWT; unset → `jwt` | + +The `--sign-files-vault-*` options exist so that the files signer and the `--sign-key` signer can live in different Vault mounts and log in under different roles within one `werf build`; each option left unset falls back to what `--sign-key` would use. + +A build of an image that declares `sign` fails before any stage runs when the key or the certificate is missing and `--sign-files` is not `false`. Commands that do not build images (`lint`, `stages copy`, `sbom get`) have no `--sign-files` flag and treat it as off: they accept such a `werf.yaml` without a key. + +With `hashivault://` the private key never leaves the KMS. The certificate is not fetched from the KMS: when it is issued by a PKI (Vault PKI, for example), the pipeline retrieves the PEM itself and passes it as a path or base64. The stage container always runs with `network: none`, whatever the image's `network` setting, and none of the image's commands run in it, so the directive works unchanged when `build.sbom.enable` is `true` and [network is disabled in shell stages]({{ "/usage/build/sbom.html" | true_relative_url }}). + +The stage is cached like any other. Its digest covers the signed paths and the signing certificate, so rotating the key rebuilds the signature and the stages after it, while a rebuild with the same key reuses the cached layer. Only the signing certificate is embedded in the `.p7s`; intermediates do not take part in the signature or the digest. diff --git a/docs/pages_ru/usage/build/sbom.md b/docs/pages_ru/usage/build/sbom.md index 3ddb754187..0eafaa44ce 100644 --- a/docs/pages_ru/usage/build/sbom.md +++ b/docs/pages_ru/usage/build/sbom.md @@ -17,7 +17,7 @@ SBOM generation requires a container registry (specify --repo) - Поддерживается только **Docker backend**. - Поддерживается только **Stapel-синтаксис** описания образов (`werf.yaml`). -- **В shell-стадиях отключена сеть**: скачать зависимости командой в любой shell-стадии (`beforeInstall`, `install`, `beforeSetup`, `setup`) не получится — установка зависимостей выполняется только декларативно через [директиву `packages`]({{ "/usage/build/stapel/instructions.html#установка-бинарных-пакетов" | true_relative_url }}). Это и гарантирует, что все зависимости учтены в SBOM. +- **В shell-стадиях отключена сеть**: скачать зависимости командой в любой shell-стадии (`beforeInstall`, `install`, `beforeSetup`, `setup`) не получится — установка зависимостей выполняется только декларативно через [директиву `packages`]({{ "/usage/build/stapel/instructions.html#установка-бинарных-пакетов" | true_relative_url }}). Это и гарантирует, что все зависимости учтены в SBOM. Результат, который должен быть подписан внешним KMS, получают через [директиву `sign`]({{ "/usage/build/stapel/imports.html#подписание-файлов-внутри-образа" | true_relative_url }}) — ей не нужна сеть внутри стадии. - SBOM формируется из **контролируемых входов**: каждая запись `packages` учитывается своим каталогизатором (для file-based типов syft читает манифест/lock-файл — `go.sum`, `package-lock.json` и т.п.; для `os-pm` — отдельный каталогизатор). Список поддерживаемых экосистем фиксированный. - **Vendored-зависимости не учитываются**: зависимости, положенные в репозиторий напрямую (`vendor/`, `third_party/` и т.п.), проходят мимо `packages` и не попадают в SBOM. Не используйте vendoring — все зависимости должны заезжать через `packages`. - SBOM существует только у образов, собранных с `build.sbom.enable: true` — у ранее собранных образов SBOM нет, их нужно пересобрать. diff --git a/docs/pages_ru/usage/build/stapel/imports.md b/docs/pages_ru/usage/build/stapel/imports.md index b8bb115611..228019ce19 100644 --- a/docs/pages_ru/usage/build/stapel/imports.md +++ b/docs/pages_ru/usage/build/stapel/imports.md @@ -94,3 +94,43 @@ import: werf создаёт только родительский каталог для `to`, поэтому существует ли сам `to`, определяет _образ назначения_ — когда разница важна, указывайте полный путь до файла. Завершающий слеш в `to` (всё, кроме `to: /`) — ошибка конфигурации, сборка падает: пишите `to: /usr/sbin`, а не `to: /usr/sbin/`. + +## Подписание файлов внутри образа + +Директива `sign` подписывает файл, который производит образ, и кладёт подпись рядом с ним, не давая сборочной стадии доступ к ключу подписи. Она занимает в цепочке стадий то же место, что и `import`: файл должен существовать после стадии, указанной в `before` или `after`, а подпись доступна последующим стадиям. + +```yaml +image: sysext +from: registry.example.com/base/ubuntu:22.04 +shell: + install: + - mkfs.erofs /out/ext.raw /src + - veritysetup format --root-hash-file=/out/ext.roothash /out/ext.raw /out/ext.verity +sign: +- path: /out/ext.roothash + after: install +``` + +werf читает файл из собранного к этому моменту образа, подписывает его на сборочном хосте и добавляет `.p7s` новым слоем. Подпись — отсоединённый PKCS#7 (CMS) `SignedData` над содержимым файла: SHA-256, RSASSA-PKCS1-v1_5, без аутентифицированных атрибутов, со встроенным сертификатом подписанта — ровно та форма, которую производит `systemd-keyutil pkcs7`, поэтому systemd и ядро Linux принимают её как подпись dm-verity root hash. Ключ, соответственно, должен быть RSA. + +Ключ и сертификат никогда не являются частью `werf.yaml`; они передаются только через командную строку, отдельно от ключа подписи манифеста и SBOM: + +| Флаг | Переменная окружения | Описание | +|---|---|---| +| `--sign-files` | `WERF_SIGN_FILES` | обрабатывать директивы `sign` (по умолчанию `true`); `false` пропускает их и собирает образ без подписей | +| `--sign-files-key` | `WERF_SIGN_FILES_KEY` | приватный ключ: путь к PEM-файлу, PEM в base64 или `hashivault://[KEY]` | +| `--sign-files-cert` | `WERF_SIGN_FILES_CERT` | сертификат подписанта: путь к PEM-файлу или PEM в base64 | +| `--sign-files-intermediates` | `WERF_SIGN_FILES_INTERMEDIATES` | промежуточные сертификаты: путь к PEM-файлу или PEM в base64 | +| `--sign-files-vault-addr` | `WERF_SIGN_FILES_VAULT_ADDR` | адрес Vault, в котором ищется ключ `hashivault://`; не задан → `VAULT_ADDR` | +| `--sign-files-vault-transit-path` | `WERF_SIGN_FILES_VAULT_TRANSIT_PATH` | mount transit-движка с ключом; не задан → `TRANSIT_SECRET_ENGINE_PATH` | +| `--sign-files-vault-auth-jwt` | `WERF_SIGN_FILES_VAULT_AUTH_JWT` | JWT для входа в Vault; не задан → та же аутентификация из окружения, что у `--sign-key` | +| `--sign-files-vault-auth-role` | `WERF_SIGN_FILES_VAULT_AUTH_ROLE` | роль Vault JWT для этого JWT | +| `--sign-files-vault-auth-path` | `WERF_SIGN_FILES_VAULT_AUTH_PATH` | mount JWT-аутентификации для этого JWT; не задан → `jwt` | + +Опции `--sign-files-vault-*` нужны, чтобы подписант файлов и подписант `--sign-key` могли жить в разных mount Vault и входить под разными ролями в рамках одного `werf build`; каждая незаданная опция берёт то, что использовал бы `--sign-key`. + +Сборка образа с директивой `sign` завершается ошибкой ещё до запуска стадий, если ключ или сертификат не заданы, а `--sign-files` не равен `false`. Команды, которые не собирают образы (`lint`, `stages copy`, `sbom get`), не имеют флага `--sign-files` и считают его выключенным: такой `werf.yaml` они принимают без ключа. + +С `hashivault://` приватный ключ не покидает KMS. Сертификат из KMS не запрашивается: если его выпускает PKI (например, Vault PKI), пайплайн сам получает PEM и передаёт его путём или в base64. Контейнер стадии всегда запускается с `network: none` независимо от настройки `network` образа, и команды образа в нём не выполняются, поэтому директива работает без изменений при `build.sbom.enable: true`, когда [в shell-стадиях отключена сеть]({{ "/usage/build/sbom.html" | true_relative_url }}). + +Стадия кэшируется как любая другая. Её дайджест включает подписываемые пути и сертификат подписанта, поэтому ротация ключа пересобирает подпись и последующие стадии, а повторная сборка с тем же ключом переиспользует закэшированный слой. В `.p7s` встраивается только сертификат подписанта; промежуточные сертификаты не участвуют ни в подписи, ни в дайджесте. diff --git a/go.mod b/go.mod index 7fb81273c8..b2ec641d6a 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/samber/lo v1.53.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sirupsen/logrus v1.10.2 + github.com/smallstep/pkcs7 v0.2.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/werf/common-go v0.0.0-20260831162604-46c3b45163be @@ -237,7 +238,6 @@ require ( github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore-go v1.2.2 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect - github.com/smallstep/pkcs7 v0.2.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.3 // indirect diff --git a/pkg/build/build_phase.go b/pkg/build/build_phase.go index 953e015cfc..a2a3642657 100644 --- a/pkg/build/build_phase.go +++ b/pkg/build/build_phase.go @@ -64,6 +64,7 @@ type BuildOptions struct { SbomSigningOptions signing.SbomSigningOptions VexSigningOptions signing.VexSigningOptions ELFSigningOptions signing.ELFSigningOptions + FilesSigningOptions signing.FilesSigningOptions VerityAnnotationOptions verify_annotation.Options ReportPath string @@ -1322,8 +1323,11 @@ func (phase *BuildPhase) getPrevNonEmptyStageCreationTs() int64 { return 0 } -func (phase *BuildPhase) getLogImageNetwork(img *image.Image) string { +func (phase *BuildPhase) getLogImageNetwork(img *image.Image, stg stage.Interface) string { network := phase.ImageBuildOptions.Network + if n, ok := stg.(interface{ Network() string }); network == "" && ok { + network = n.Network() + } if network == "" { if img.IsDockerfileImage && img.DockerfileImageConfig != nil { network = img.DockerfileImageConfig.Network @@ -1403,7 +1407,7 @@ func (phase *BuildPhase) onImageStage(ctx context.Context, img *image.Image, stg if foundSuitableStage { phase.countStageCacheHit(ctx) logboek.Context(ctx).Default().LogFHighlight("Use previously built image for %s\n", stg.LogDetailedName()) - container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) + container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img, stg)) logboek.Context(ctx).LogOptionalLn() @@ -1568,7 +1572,7 @@ func (phase *BuildPhase) findAndFetchStageFromSecondaryStagesStorage(ctx context } logboek.Context(ctx).Default().LogFHighlight("Use previously built image for %s\n", stg.LogDetailedName()) - container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) + container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img, stg)) return nil } @@ -1924,7 +1928,7 @@ func (phase *BuildPhase) buildStage(ctx context.Context, img *image.Image, stg s if err != nil { return } - container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) + container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img, stg)) } var reusedPublishedStage bool diff --git a/pkg/build/conveyor.go b/pkg/build/conveyor.go index 8bbed260c3..7b39af39d2 100644 --- a/pkg/build/conveyor.go +++ b/pkg/build/conveyor.go @@ -91,6 +91,7 @@ type ConveyorOptions struct { ManifestSigningOptions signing.ManifestSigningOptions SbomSigningOptions signing.SbomSigningOptions VexSigningOptions signing.VexSigningOptions + FilesSigningOptions signing.FilesSigningOptions VerityAnnotationOptions verify_annotation.Options UseBuildReport bool BuildReportPath string @@ -142,6 +143,7 @@ func NewConveyor(werfConfig *config.WerfConfig, giterminismManager giterminism_m ContainerWerfDir: c.containerWerfDir, TmpDir: c.tmpDir, ManifestSigningOptions: opts.ManifestSigningOptions, + FilesSigningOptions: opts.FilesSigningOptions, VerityAnnotationOptions: opts.VerityAnnotationOptions, }, ImagesToProcess: opts.ImagesToProcess, diff --git a/pkg/build/image/image.go b/pkg/build/image/image.go index d2bd2fc61a..2061786e66 100644 --- a/pkg/build/image/image.go +++ b/pkg/build/image/image.go @@ -51,6 +51,7 @@ type CommonImageOptions struct { ForceTargetPlatformLogging bool ManifestSigningOptions signing.ManifestSigningOptions + FilesSigningOptions signing.FilesSigningOptions VerityAnnotationOptions verify_annotation.Options } diff --git a/pkg/build/image/stapel.go b/pkg/build/image/stapel.go index e8fcb82f93..158074bf7d 100644 --- a/pkg/build/image/stapel.go +++ b/pkg/build/image/stapel.go @@ -119,6 +119,7 @@ func initStages(ctx context.Context, image *Image, metaConfig *config.Meta, stap stages = appendIfExist(stages, stage.GenerateFromStage(imageBaseConfig, image.baseImageRepoId, imageCacheVersion, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateBeforeInstallStage(ctx, imageBaseConfig, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateDependenciesBeforeInstallStage(imageBaseConfig, baseStageOptions)) + stages = appendIfExist(stages, stage.GenerateSignFilesBeforeInstallStage(imageBaseConfig, opts.FilesSigningOptions, baseStageOptions)) if gitMappingsExist { stages = append(stages, stage.NewGitArchiveStage(gitArchiveStageOptions, baseStageOptions)) @@ -128,10 +129,13 @@ func initStages(ctx context.Context, image *Image, metaConfig *config.Meta, stap stages = appendIfExist(stages, stage.GenerateInstallStage(ctx, imageBaseConfig, gitPatchStageOptions, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateDependenciesAfterInstallStage(imageBaseConfig, baseStageOptions)) + stages = appendIfExist(stages, stage.GenerateSignFilesAfterInstallStage(imageBaseConfig, opts.FilesSigningOptions, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateBeforeSetupStage(ctx, imageBaseConfig, gitPatchStageOptions, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateDependenciesBeforeSetupStage(imageBaseConfig, baseStageOptions)) + stages = appendIfExist(stages, stage.GenerateSignFilesBeforeSetupStage(imageBaseConfig, opts.FilesSigningOptions, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateSetupStage(ctx, imageBaseConfig, gitPatchStageOptions, baseStageOptions)) stages = appendIfExist(stages, stage.GenerateDependenciesAfterSetupStage(imageBaseConfig, baseStageOptions)) + stages = appendIfExist(stages, stage.GenerateSignFilesAfterSetupStage(imageBaseConfig, opts.FilesSigningOptions, baseStageOptions)) if gitMappingsExist { stages = append(stages, stage.NewGitCacheStage(gitPatchStageOptions, baseStageOptions)) diff --git a/pkg/build/image/stapel_test.go b/pkg/build/image/stapel_test.go index 3067d1239c..7072143093 100644 --- a/pkg/build/image/stapel_test.go +++ b/pkg/build/image/stapel_test.go @@ -7,6 +7,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/werf/werf/v3/pkg/build/signing" "github.com/werf/werf/v3/pkg/build/stage" "github.com/werf/werf/v3/pkg/config" ) @@ -111,3 +112,90 @@ packages: Entry("build.sbom.enable true", "build:\n sbom:\n enable: true\n standard: cyclonedx@1.6"), ) }) + +var _ = Describe("sign files stages", func() { + werfYaml := func(sbomBlock string) string { + return fmt.Sprintf(`project: test +configVersion: 1 +%s +--- +image: app +from: alpine:3.20 +shell: + install: + - echo install + setup: + - echo setup +sign: +- path: /etc/os-release + before: install +- path: /out/a.roothash + after: install +- path: /out/b.roothash + before: setup +- path: /out/c.roothash + after: setup +`, sbomBlock) + } + + newImage := func(ctx context.Context, sbomBlock string, filesSigning signing.FilesSigningOptions) *Image { + projectDir := newProjectRepo(ctx, map[string]string{"werf.yaml": werfYaml(sbomBlock)}) + metaConfig, imageConfig, opts := stapelImageConfig(ctx, projectDir) + opts.FilesSigningOptions = filesSigning + + image, err := mapStapelConfigToImage(ctx, metaConfig, imageConfig, "linux/amd64", false, opts) + Expect(err).NotTo(HaveOccurred()) + return image + } + + enabled := func() signing.FilesSigningOptions { + o := signing.NewFilesSigningOptions(&signing.Signer{}) + o.Enabled = true + return o + } + + It("places one stage per position right after the matching dependencies position", func() { + image := newImage(context.Background(), "", enabled()) + + names := stageNames(image) + Expect(names).To(Equal([]stage.StageName{ + stage.From, + stage.SignFilesBeforeInstall, + stage.Install, + stage.SignFilesAfterInstall, + stage.SignFilesBeforeSetup, + stage.Setup, + stage.SignFilesAfterSetup, + })) + }) + + It("generates no stage when files signing is disabled", func() { + image := newImage(context.Background(), "", signing.NewFilesSigningOptions(&signing.Signer{})) + + for _, name := range []stage.StageName{stage.SignFilesBeforeInstall, stage.SignFilesAfterInstall, stage.SignFilesBeforeSetup, stage.SignFilesAfterSetup} { + Expect(stageNames(image)).NotTo(ContainElement(name)) + } + }) + + DescribeTable("runs the sign stages without network", + func(sbomBlock string) { + image := newImage(context.Background(), sbomBlock, enabled()) + + for _, name := range []stage.StageName{stage.SignFilesBeforeInstall, stage.SignFilesAfterInstall, stage.SignFilesBeforeSetup, stage.SignFilesAfterSetup} { + s := image.GetStage(name) + Expect(s).NotTo(BeNil(), string(name)) + Expect(s.(*stage.SignFilesStage).Network()).To(Equal("none"), string(name)) + } + }, + Entry("build.sbom absent", ""), + Entry("build.sbom.enable true", "build:\n sbom:\n enable: true\n standard: cyclonedx@1.6"), + ) + + It("keeps shell stages offline under build.sbom.enable", func() { + image := newImage(context.Background(), "build:\n sbom:\n enable: true\n standard: cyclonedx@1.6", enabled()) + + for _, name := range []stage.StageName{stage.Install, stage.Setup} { + Expect(image.GetStage(name).(interface{ Network() string }).Network()).To(Equal("none"), string(name)) + } + }) +}) diff --git a/pkg/build/signing/files_signing.go b/pkg/build/signing/files_signing.go new file mode 100644 index 0000000000..d067951bf5 --- /dev/null +++ b/pkg/build/signing/files_signing.go @@ -0,0 +1,121 @@ +package signing + +import ( + "bytes" + "context" + "crypto" + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" + "io" + + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/options" + "github.com/smallstep/pkcs7" +) + +type FilesSigningOptions struct { + Enabled bool + + signer *Signer +} + +func (o FilesSigningOptions) Signer() *Signer { + return o.signer +} + +func NewFilesSigningOptions(signer *Signer) FilesSigningOptions { + return FilesSigningOptions{signer: signer} +} + +// SignDetachedPKCS7 wraps an RSASSA-PKCS1-v1_5/SHA-256 signature of content into a +// detached CMS SignedData with the leaf certificate embedded and no authenticated +// attributes: the profile systemd-keyutil writes and the kernel dm-verity signature +// check and systemd-dissect read. The signature is checked against the leaf +// certificate before it is returned, so a Vault answer that does not belong to the +// certificate stops the build here. +func SignDetachedPKCS7(ctx context.Context, content []byte, signer *Signer) ([]byte, error) { + if signer == nil || signer.sv == nil { + return nil, errors.New("signer is not configured") + } + + leaf, err := parseLeafCertificate(signer.Cert()) + if err != nil { + return nil, err + } + leafPub, ok := leaf.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("signing certificate carries a %T public key, only RSA is supported for detached PKCS#7", leaf.PublicKey) + } + + sd, err := pkcs7.NewSignedData(content) + if err != nil { + return nil, fmt.Errorf("create signed data: %w", err) + } + sd.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256) + + cs := &digestSigner{ctx: ctx, sv: signer.sv, pub: leafPub} + if err := sd.SignWithoutAttr(leaf, cs, pkcs7.SignerInfoConfig{}); err != nil { + return nil, fmt.Errorf("sign content: %w", err) + } + if len(cs.signature) == 0 { + return nil, errors.New("signing produced no signature") + } + if err := rsa.VerifyPKCS1v15(leafPub, crypto.SHA256, cs.digest, cs.signature); err != nil { + return nil, fmt.Errorf("the signing certificate does not verify the signature the key produced: %w", err) + } + + sd.Detach() + der, err := sd.Finish() + if err != nil { + return nil, fmt.Errorf("encode signed data: %w", err) + } + return der, nil +} + +func parseLeafCertificate(pemData string) (*x509.Certificate, error) { + certs, err := cryptoutils.UnmarshalCertificatesFromPEM([]byte(pemData)) + if err != nil { + return nil, fmt.Errorf("parse signing certificate: %w", err) + } + if len(certs) == 0 { + return nil, errors.New("signing certificate is empty") + } + return certs[0], nil +} + +// digestSigner adapts a sigstore signer to crypto.Signer for pkcs7, which hands over +// the content digest; the signer receives it as is instead of hashing again. +type digestSigner struct { + ctx context.Context + sv signature.Signer + pub crypto.PublicKey + + digest []byte + signature []byte +} + +var _ crypto.Signer = (*digestSigner)(nil) + +func (s *digestSigner) Public() crypto.PublicKey { + return s.pub +} + +func (s *digestSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + if opts == nil || opts.HashFunc() != crypto.SHA256 { + return nil, fmt.Errorf("unsupported digest for detached PKCS#7: %v", opts) + } + sig, err := s.sv.SignMessage(bytes.NewReader(nil), + options.WithContext(s.ctx), + options.WithDigest(digest), + options.WithCryptoSignerOpts(crypto.SHA256), + ) + if err != nil { + return nil, fmt.Errorf("sign digest with the files signing key: %w", err) + } + s.digest = digest + s.signature = sig + return sig, nil +} diff --git a/pkg/build/signing/files_signing_test.go b/pkg/build/signing/files_signing_test.go new file mode 100644 index 0000000000..6798a49f02 --- /dev/null +++ b/pkg/build/signing/files_signing_test.go @@ -0,0 +1,116 @@ +package signing + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/deckhouse/delivery-kit-sdk/pkg/signver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/secure-systems-lab/go-securesystemslib/encrypted" + "github.com/smallstep/pkcs7" +) + +func TestFilesSigning(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Files signing suite") +} + +func newTestSigner(ctx context.Context) (*Signer, *rsa.PrivateKey) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "files-signing-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()) + + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + encryptedKey, err := encrypted.Encrypt(keyDER, nil) + Expect(err).NotTo(HaveOccurred()) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: signver.PrivateKeyPemType, Bytes: encryptedKey}) + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + signer, err := NewSigner(ctx, SignerOptions{ + KeyRef: base64.StdEncoding.EncodeToString(keyPEM), + CertRef: base64.StdEncoding.EncodeToString(certPEM), + }) + Expect(err).NotTo(HaveOccurred()) + return signer, key +} + +var _ = Describe("SignDetachedPKCS7", func() { + // The content a dm-verity root hash signature covers: the hex digest, no newline. + content := []byte("3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b") + + It("produces a detached SignedData without authenticated attributes that verifies against the leaf", func(ctx SpecContext) { + signer, key := newTestSigner(ctx) + + der, err := SignDetachedPKCS7(ctx, content, signer) + Expect(err).NotTo(HaveOccurred()) + + p7, err := pkcs7.Parse(der) + Expect(err).NotTo(HaveOccurred()) + Expect(p7.Content).To(BeEmpty(), "signature must be detached") + Expect(p7.Certificates).To(HaveLen(1), "the leaf certificate must be embedded") + Expect(p7.Signers).To(HaveLen(1)) + Expect(p7.Signers[0].AuthenticatedAttributes).To(BeEmpty(), "systemd-keyutil profile carries no authenticated attributes") + Expect(p7.Certificates[0].PublicKey).To(Equal(&key.PublicKey)) + + digest := sha256.Sum256(content) + Expect(rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], p7.Signers[0].EncryptedDigest)).To(Succeed(), + "the embedded signature must be PKCS#1 v1.5 over sha256(content), the form the kernel checks") + + p7.Content = content + Expect(p7.Verify()).To(Succeed()) + }) + + It("is accepted by openssl as a detached signature of the content", func(ctx SpecContext) { + if _, err := exec.LookPath("openssl"); err != nil { + Skip("openssl is not installed") + } + signer, _ := newTestSigner(ctx) + + der, err := SignDetachedPKCS7(ctx, content, signer) + Expect(err).NotTo(HaveOccurred()) + + dir := GinkgoT().TempDir() + p7sPath := filepath.Join(dir, "x.roothash.p7s") + contentPath := filepath.Join(dir, "x.roothash") + Expect(os.WriteFile(p7sPath, der, 0o600)).To(Succeed()) + Expect(os.WriteFile(contentPath, content, 0o600)).To(Succeed()) + + out, err := exec.CommandContext(ctx, "openssl", "smime", "-verify", "-inform", "der", "-in", p7sPath, "-content", contentPath, "-noverify", "-out", os.DevNull).CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(out)) + + tampered := append([]byte{}, content...) + tampered[0] ^= 1 + Expect(os.WriteFile(contentPath, tampered, 0o600)).To(Succeed()) + _, err = exec.CommandContext(ctx, "openssl", "smime", "-verify", "-inform", "der", "-in", p7sPath, "-content", contentPath, "-noverify", "-out", os.DevNull).CombinedOutput() + Expect(err).To(HaveOccurred(), "openssl must reject the signature over different content") + }) + + It("fails when the signer is not configured", func(ctx SpecContext) { + _, err := SignDetachedPKCS7(ctx, content, &Signer{}) + Expect(err).To(MatchError(ContainSubstring("signer is not configured"))) + }) +}) diff --git a/pkg/build/signing/signer.go b/pkg/build/signing/signer.go index 4401cc71b7..450640a4d6 100644 --- a/pkg/build/signing/signer.go +++ b/pkg/build/signing/signer.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/deckhouse/delivery-kit-sdk/pkg/signver" + "github.com/deckhouse/delivery-kit-sdk/pkg/signver/hashivault" "github.com/sigstore/sigstore/pkg/cryptoutils" ) @@ -15,12 +16,37 @@ type SignerOptions struct { KeyRef string CertRef string IntermediatesRef string + Vault VaultOptions } func (o SignerOptions) IsZero() bool { return o.KeyRef == "" || o.CertRef == "" } +// VaultOptions configures the Vault a hashivault:// key is resolved against. Every +// field is optional: an empty one falls back to the SDK's environment lookup, so a +// signer with zero VaultOptions behaves exactly as before. Setting AuthJWT switches +// authentication to that static JWT; AuthRole and AuthPath qualify it and are +// ignored without it. +type VaultOptions struct { + Address string + TransitPath string + AuthJWT string + AuthRole string + AuthPath string +} + +func (o VaultOptions) sdkOpts() hashivault.VaultOpts { + opts := hashivault.VaultOpts{ + Address: o.Address, + TransitSecretEnginePath: o.TransitPath, + } + if o.AuthJWT != "" { + opts.Auth = &hashivault.VaultAuth{JWT: &hashivault.JWTAuth{JWT: o.AuthJWT, Role: o.AuthRole, Path: o.AuthPath}} + } + return opts +} + type Signer struct { sv *signver.SignerVerifier @@ -76,8 +102,9 @@ func NewSigner(ctx context.Context, opts SignerOptions) (*Signer, error) { return &Signer{}, nil } sv, err := signver.NewSignerVerifier(ctx, opts.CertRef, opts.IntermediatesRef, signver.KeyOpts{ - KeyRef: opts.KeyRef, - PassFunc: cryptoutils.SkipPassword, + KeyRef: opts.KeyRef, + PassFunc: cryptoutils.SkipPassword, + SignerVerifierOpts: signver.SignerVerifierOpts{VaultOpts: opts.Vault.sdkOpts()}, }) if err != nil { return nil, fmt.Errorf("failed to create signer verifier: %w", err) diff --git a/pkg/build/signing/signer_vault_test.go b/pkg/build/signing/signer_vault_test.go new file mode 100644 index 0000000000..251ade6ca2 --- /dev/null +++ b/pkg/build/signing/signer_vault_test.go @@ -0,0 +1,136 @@ +package signing + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeVaultLogin struct { + path string + role string + jwt string +} + +type fakeVault struct { + *httptest.Server + + publicKeyPEM string + mu sync.Mutex + logins []fakeVaultLogin + keyReads []string +} + +func newFakeVault(publicKeyPEM string) *fakeVault { + v := &fakeVault{publicKeyPEM: publicKeyPEM} + v.Server = httptest.NewServer(http.HandlerFunc(v.handle)) + return v +} + +func (v *fakeVault) handle(w http.ResponseWriter, r *http.Request) { + v.mu.Lock() + defer v.mu.Unlock() + + switch { + case strings.HasPrefix(r.URL.Path, "/v1/auth/"): + var body map[string]string + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + v.logins = append(v.logins, fakeVaultLogin{path: r.URL.Path, role: body["role"], jwt: body["jwt"]}) + fmt.Fprintf(w, `{"auth":{"client_token":"s.fake","lease_duration":3600}}`) + default: + v.keyReads = append(v.keyReads, r.URL.Path+"|"+r.Header.Get("X-Vault-Token")) + key := map[string]any{"public_key": v.publicKeyPEM, "name": "rsa-2048"} + Expect(json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "latest_version": 1, + "keys": map[string]any{"1": key}, + }})).To(Succeed()) + } +} + +var _ = Describe("NewSigner with hashivault", func() { + var vault *fakeVault + var certRef string + + BeforeEach(func() { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + Expect(err).NotTo(HaveOccurred()) + vault = newFakeVault(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))) + DeferCleanup(vault.Close) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "vault-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()) + certRef = base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + + GinkgoT().Setenv("VAULT_ADDR", "http://127.0.0.1:1") + GinkgoT().Setenv("TRANSIT_SECRET_ENGINE_PATH", "env-transit") + GinkgoT().Setenv("WERF_VAULT_AUTH_JWT", "env-jwt") + GinkgoT().Setenv("WERF_VAULT_AUTH_ROLE", "env-role") + GinkgoT().Setenv("WERF_VAULT_AUTH_PATH", "env-auth") + }) + + It("logs in and reads the key where the Vault options say, not where the environment does", func(ctx SpecContext) { + _, err := NewSigner(ctx, SignerOptions{ + KeyRef: "hashivault://engine-key", + CertRef: certRef, + Vault: VaultOptions{ + Address: vault.URL, + TransitPath: "dh-engine-signer", + AuthJWT: "files-jwt", + AuthRole: "files-role", + AuthPath: "fox", + }, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(vault.logins).To(Equal([]fakeVaultLogin{{path: "/v1/auth/fox/login", role: "files-role", jwt: "files-jwt"}})) + Expect(vault.keyReads).To(Equal([]string{"/v1/dh-engine-signer/keys/engine-key|s.fake"})) + }) + + It("falls back to the environment for every option left empty", func(ctx SpecContext) { + GinkgoT().Setenv("VAULT_ADDR", vault.URL) + + _, err := NewSigner(ctx, SignerOptions{ + KeyRef: "hashivault://engine-key", + CertRef: certRef, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(vault.logins).To(Equal([]fakeVaultLogin{{path: "/v1/auth/env-auth/login", role: "env-role", jwt: "env-jwt"}})) + Expect(vault.keyReads).To(Equal([]string{"/v1/env-transit/keys/engine-key|s.fake"})) + }) + + It("keeps the environment authentication when only the address and transit mount are given", func(ctx SpecContext) { + _, err := NewSigner(ctx, SignerOptions{ + KeyRef: "hashivault://engine-key", + CertRef: certRef, + Vault: VaultOptions{Address: vault.URL, TransitPath: "dh-engine-signer"}, + }) + Expect(err).NotTo(HaveOccurred()) + + Expect(vault.logins).To(Equal([]fakeVaultLogin{{path: "/v1/auth/env-auth/login", role: "env-role", jwt: "env-jwt"}})) + Expect(vault.keyReads).To(Equal([]string{"/v1/dh-engine-signer/keys/engine-key|s.fake"})) + }) +}) diff --git a/pkg/build/stage/base.go b/pkg/build/stage/base.go index 8553088388..5c6865888e 100644 --- a/pkg/build/stage/base.go +++ b/pkg/build/stage/base.go @@ -135,6 +135,15 @@ func (s *BaseStage) NeedsNetwork() bool { return s.needsNetwork } +// Network returns the networking mode the stage container will run with: the +// override when one is set, otherwise the mode the image config asked for. +func (s *BaseStage) Network() string { + if s.networkOverride != "" { + return s.networkOverride + } + return s.network +} + func (s *BaseStage) IsMutable() bool { return false } @@ -292,12 +301,7 @@ func (s *BaseStage) PrepareImage(ctx context.Context, c Conveyor, cb container_b s.addProjectRepoCommitLabel(ctx, c, cb, stageImage) - network := s.network - if s.networkOverride != "" { - network = s.networkOverride - } - - if network != "" { + if network := s.Network(); network != "" { if c.UseLegacyStapelBuilder(cb) { stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddNetwork(network) } else { diff --git a/pkg/build/stage/sign_files.go b/pkg/build/stage/sign_files.go new file mode 100644 index 0000000000..55bb561e1a --- /dev/null +++ b/pkg/build/stage/sign_files.go @@ -0,0 +1,169 @@ +package stage + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + + "al.essio.dev/pkg/shellescape" + + "github.com/werf/common-go/pkg/util" + "github.com/werf/logboek" + "github.com/werf/werf/v3/pkg/build/signing" + "github.com/werf/werf/v3/pkg/config" + "github.com/werf/werf/v3/pkg/container_backend" + "github.com/werf/werf/v3/pkg/stapel" +) + +const ( + SignFilesBeforeInstall StageName = "signFilesBeforeInstall" + SignFilesAfterInstall StageName = "signFilesAfterInstall" + SignFilesBeforeSetup StageName = "signFilesBeforeSetup" + SignFilesAfterSetup StageName = "signFilesAfterSetup" +) + +// SignFilesStage signs the files the sign directive places at one position of the +// stage chain. The files are read out of the previous built image, signed on the host +// by the files signer and the detached PKCS#7 signatures are laid down next to them as +// a new layer; nothing of the user's runs in the stage container and it needs no network. +type SignFilesStage struct { + *BaseStage + + signs []*config.Sign + options signing.FilesSigningOptions +} + +var _ Interface = (*SignFilesStage)(nil) + +func GenerateSignFilesBeforeInstallStage(imageBaseConfig *config.StapelImageBase, options signing.FilesSigningOptions, baseStageOptions *BaseStageOptions) *SignFilesStage { + return generateSignFilesStage(imageBaseConfig, SignFilesBeforeInstall, &getImportsOptions{Before: Install}, options, baseStageOptions) +} + +func GenerateSignFilesAfterInstallStage(imageBaseConfig *config.StapelImageBase, options signing.FilesSigningOptions, baseStageOptions *BaseStageOptions) *SignFilesStage { + return generateSignFilesStage(imageBaseConfig, SignFilesAfterInstall, &getImportsOptions{After: Install}, options, baseStageOptions) +} + +func GenerateSignFilesBeforeSetupStage(imageBaseConfig *config.StapelImageBase, options signing.FilesSigningOptions, baseStageOptions *BaseStageOptions) *SignFilesStage { + return generateSignFilesStage(imageBaseConfig, SignFilesBeforeSetup, &getImportsOptions{Before: Setup}, options, baseStageOptions) +} + +func GenerateSignFilesAfterSetupStage(imageBaseConfig *config.StapelImageBase, options signing.FilesSigningOptions, baseStageOptions *BaseStageOptions) *SignFilesStage { + return generateSignFilesStage(imageBaseConfig, SignFilesAfterSetup, &getImportsOptions{After: Setup}, options, baseStageOptions) +} + +func generateSignFilesStage(imageBaseConfig *config.StapelImageBase, name StageName, position *getImportsOptions, options signing.FilesSigningOptions, baseStageOptions *BaseStageOptions) *SignFilesStage { + if !options.Enabled { + return nil + } + + var signs []*config.Sign + for _, s := range imageBaseConfig.Sign { + if (position.After != "" && s.After == string(position.After)) || (position.Before != "" && s.Before == string(position.Before)) { + signs = append(signs, s) + } + } + if len(signs) == 0 { + return nil + } + + stage := &SignFilesStage{ + BaseStage: NewBaseStage(name, baseStageOptions), + signs: signs, + options: options, + } + stage.SetNetworkOverride("none") + return stage +} + +func (s *SignFilesStage) GetDependencies(_ context.Context, _ Conveyor, _ container_backend.ContainerBackend, _, _ *StageImage, _ container_backend.BuildContextArchiver) (string, error) { + return s.digest(), nil +} + +func (s *SignFilesStage) GetContentDependencies(_ context.Context, _ Conveyor, _ container_backend.BuildContextArchiver) (string, error) { + return s.digest(), nil +} + +func (s *SignFilesStage) digest() string { + args := []string{string(s.Name())} + for _, sign := range s.signs { + args = append(args, "Path", sign.Path) + } + args = append(args, "Cert", s.options.Signer().Cert()) + return util.Sha256Hash(args...) +} + +func (s *SignFilesStage) PrepareImage(ctx context.Context, c Conveyor, cb container_backend.ContainerBackend, prevBuiltImage, stageImage *StageImage, buildContextArchive container_backend.BuildContextArchiver) error { + if err := s.BaseStage.PrepareImage(ctx, c, cb, prevBuiltImage, stageImage, buildContextArchive); err != nil { + return err + } + + signaturesDir := filepath.Join(s.imageTmpDir, "sign", string(s.Name())) + if err := os.MkdirAll(signaturesDir, os.ModePerm); err != nil { + return fmt.Errorf("create signatures dir %s: %w", signaturesDir, err) + } + + legacy := c.UseLegacyStapelBuilder(cb) + var commands []string + for _, sign := range s.signs { + content, err := cb.ReadFileFromImage(ctx, prevBuiltImage.Image.Name(), sign.Path, container_backend.ReadFileFromImageOpts{TargetPlatform: s.targetPlatform}) + if err != nil { + return fmt.Errorf("read %s to sign from image %q: %w", sign.Path, s.imageName, err) + } + + var signature []byte + if err := logboek.Context(ctx).Default().LogProcess("Signing %s", sign.Path).DoError(func() error { + var signErr error + signature, signErr = signing.SignDetachedPKCS7(ctx, content, s.options.Signer()) + return signErr + }); err != nil { + return fmt.Errorf("sign %s of image %q: %w", sign.Path, s.imageName, err) + } + + outputName := path.Base(sign.OutputPath()) + if legacy { + hostPath := filepath.Join(signaturesDir, outputName) + if err := os.WriteFile(hostPath, signature, 0o644); err != nil { + return fmt.Errorf("write signature %s: %w", hostPath, err) + } + containerPath := path.Join(s.containerWerfDir, "sign", string(s.Name()), outputName) + commands = append(commands, + fmt.Sprintf("%s -p %s", stapel.MkdirBinPath(), shellescape.Quote(path.Dir(sign.OutputPath()))), + fmt.Sprintf("%s -m 0644 %s %s", stapel.InstallBinPath(), shellescape.Quote(containerPath), shellescape.Quote(sign.OutputPath())), + ) + continue + } + + archive, err := singleFileTar(outputName, signature) + if err != nil { + return fmt.Errorf("pack signature of %s: %w", sign.Path, err) + } + stageImage.Builder.StapelStageBuilder().AddDataArchive(io.NopCloser(archive), container_backend.FileArchive, sign.OutputPath(), container_backend.AddDataArchiveOptions{}) + } + + if legacy { + stageImage.Builder.LegacyStapelStageBuilder().Container().RunOptions().AddVolume(fmt.Sprintf("%s:%s:ro", signaturesDir, path.Join(s.containerWerfDir, "sign", string(s.Name())))) + stageImage.Builder.LegacyStapelStageBuilder().Container().AddServiceRunCommands(commands...) + } + + return nil +} + +func singleFileTar(name string, content []byte) (*bytes.Buffer, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content)), Typeflag: tar.TypeReg}); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + if err := tw.Close(); err != nil { + return nil, err + } + return &buf, nil +} diff --git a/pkg/build/stage/sign_files_test.go b/pkg/build/stage/sign_files_test.go new file mode 100644 index 0000000000..76a3fcaabf --- /dev/null +++ b/pkg/build/stage/sign_files_test.go @@ -0,0 +1,91 @@ +package stage + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "math/big" + "time" + + "github.com/deckhouse/delivery-kit-sdk/pkg/signver" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/secure-systems-lab/go-securesystemslib/encrypted" + + "github.com/werf/werf/v3/pkg/build/signing" + "github.com/werf/werf/v3/pkg/config" +) + +var _ = Describe("SignFilesStage", func() { + newSigner := func(cn string) *signing.Signer { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + 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()) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + Expect(err).NotTo(HaveOccurred()) + encryptedKey, err := encrypted.Encrypt(keyDER, nil) + Expect(err).NotTo(HaveOccurred()) + + signer, err := signing.NewSigner(context.Background(), signing.SignerOptions{ + KeyRef: base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: signver.PrivateKeyPemType, Bytes: encryptedKey})), + CertRef: base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})), + }) + Expect(err).NotTo(HaveOccurred()) + return signer + } + + enabledOptions := func(signer *signing.Signer) signing.FilesSigningOptions { + options := signing.NewFilesSigningOptions(signer) + options.Enabled = true + return options + } + + digestOf := func(signer *signing.Signer, paths ...string) string { + imageConfig := &config.StapelImageBase{} + for _, p := range paths { + imageConfig.Sign = append(imageConfig.Sign, &config.Sign{Path: p, After: "install"}) + } + stage := GenerateSignFilesAfterInstallStage(imageConfig, enabledOptions(signer), &BaseStageOptions{}) + Expect(stage).NotTo(BeNil()) + + digest, err := stage.GetDependencies(context.Background(), nil, nil, nil, nil, nil) + Expect(err).NotTo(HaveOccurred()) + contentDigest, err := stage.GetContentDependencies(context.Background(), nil, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(contentDigest).To(Equal(digest), "the content digest must be the same input set") + return digest + } + + It("keeps the digest stable for the same paths and signer and changes it with either", func() { + signerA := newSigner("a") + signerB := newSigner("b") + + base := digestOf(signerA, "/out/x") + Expect(digestOf(signerA, "/out/x")).To(Equal(base)) + Expect(digestOf(signerA, "/out/y")).NotTo(Equal(base), "a different signed path must change the digest") + Expect(digestOf(signerB, "/out/x")).NotTo(Equal(base), "a different signing certificate must change the digest") + }) + + It("is generated only for the position the directive names and only when enabled", func() { + imageConfig := &config.StapelImageBase{Sign: []*config.Sign{{Path: "/out/x", Before: "install"}}} + options := enabledOptions(newSigner("a")) + + Expect(GenerateSignFilesAfterInstallStage(imageConfig, options, &BaseStageOptions{})).To(BeNil()) + Expect(GenerateSignFilesBeforeInstallStage(imageConfig, options, &BaseStageOptions{})).NotTo(BeNil()) + + options.Enabled = false + Expect(GenerateSignFilesBeforeInstallStage(imageConfig, options, &BaseStageOptions{})).To(BeNil()) + }) +}) diff --git a/pkg/config/raw_sign.go b/pkg/config/raw_sign.go new file mode 100644 index 0000000000..d67b2ce26b --- /dev/null +++ b/pkg/config/raw_sign.go @@ -0,0 +1,50 @@ +package config + +type rawSign struct { + Path string `yaml:"path,omitempty"` + Before string `yaml:"before,omitempty"` + After string `yaml:"after,omitempty"` + + rawStapelImage *rawStapelImage `yaml:"-"` // parent + + UnsupportedAttributes map[string]interface{} `yaml:",inline"` +} + +func (c *rawSign) configSection() interface{} { + return c +} + +func (c *rawSign) doc() *doc { + return c.rawStapelImage.doc +} + +func (c *rawSign) UnmarshalYAML(unmarshal func(interface{}) error) error { + if parent, ok := parentStack.Peek().(*rawStapelImage); ok { + c.rawStapelImage = parent + } + + parentStack.Push(c) + type plain rawSign + err := unmarshal((*plain)(c)) + parentStack.Pop() + if err != nil { + return err + } + + return checkOverflow(c.UnsupportedAttributes, c, c.rawStapelImage.doc) +} + +func (c *rawSign) toDirective() (*Sign, error) { + sign := &Sign{ + Path: c.Path, + Before: c.Before, + After: c.After, + raw: c, + } + + if err := sign.validate(); err != nil { + return nil, err + } + + return sign, nil +} diff --git a/pkg/config/raw_stapel_image.go b/pkg/config/raw_stapel_image.go index bf8c3af4ca..0afe538320 100644 --- a/pkg/config/raw_stapel_image.go +++ b/pkg/config/raw_stapel_image.go @@ -21,6 +21,7 @@ type rawStapelImage struct { RawShell *rawShell `yaml:"shell,omitempty"` RawMount []*rawMount `yaml:"mount,omitempty"` RawImport []*rawImport `yaml:"import,omitempty"` + RawSign []*rawSign `yaml:"sign,omitempty"` RawDependencies []*rawDependency `yaml:"dependencies,omitempty"` Platform []string `yaml:"platform,omitempty"` Network string `yaml:"network,omitempty"` @@ -195,6 +196,20 @@ func (c *rawStapelImage) toStapelImageBaseDirective(ctx context.Context, gitermi return nil, err } + signPaths := map[string]struct{}{} + for _, rawSign := range c.RawSign { + signDirective, err := rawSign.toDirective() + if err != nil { + return nil, err + } + if _, exist := signPaths[signDirective.Path]; exist { + return nil, newDetailedConfigError(fmt.Sprintf("duplicated `path: %s` for sign!", signDirective.Path), rawSign, c.doc) + } + signPaths[signDirective.Path] = struct{}{} + + imageBase.Sign = append(imageBase.Sign, signDirective) + } + for _, rawDep := range c.RawDependencies { dependencyDirective, err := rawDep.toDirective() if err != nil { diff --git a/pkg/config/raw_stapel_image_test.go b/pkg/config/raw_stapel_image_test.go index 48af53aa09..f566599b14 100644 --- a/pkg/config/raw_stapel_image_test.go +++ b/pkg/config/raw_stapel_image_test.go @@ -43,6 +43,9 @@ var _ = Describe("rawStapelImage", func() { Expect(err).To(Succeed()) stapelImage.StapelImageBase.raw = nil // set to nil for correct deep comparison + for _, sign := range stapelImage.StapelImageBase.Sign { + sign.raw = nil + } Expect(&stapelImage).To(Equal(&expectedImage)) }, Entry( @@ -65,6 +68,32 @@ var _ = Describe("rawStapelImage", func() { }, }, ), + Entry( + "should handle sign", + map[string]interface{}{ + "image": "image1", + "from": "alpine:latest", + "sign": []map[string]interface{}{ + {"path": "/out/a.roothash", "after": "install"}, + {"path": "/out/b.roothash", "before": "setup"}, + }, + }, + &StapelImage{ + StapelImageBase: &StapelImageBase{ + Name: "image1", + From: "alpine:latest", + Git: &GitManager{}, + Secrets: []Secret{}, + Sign: []*Sign{ + {Path: "/out/a.roothash", After: "install"}, + {Path: "/out/b.roothash", Before: "setup"}, + }, + + platform: []string{}, + final: true, + }, + }, + ), ) DescribeTable("unmarshal and convert to directive succeed and produce expected Dependencies", @@ -322,5 +351,64 @@ var _ = Describe("rawStapelImage", func() { }}, }, ), + Entry( + "with sign missing path", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"after": "install"}}, + }, + ), + Entry( + "with sign relative path", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "out/x.roothash", "after": "install"}}, + }, + ), + Entry( + "with sign root path", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/", "after": "install"}}, + }, + ), + Entry( + "with sign missing before/after", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/out/x.roothash"}}, + }, + ), + Entry( + "with sign both before and after", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/out/x.roothash", "before": "setup", "after": "install"}}, + }, + ), + Entry( + "with sign invalid stage", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{{"path": "/out/x.roothash", "after": "beforeInstall"}}, + }, + ), + Entry( + "with sign duplicated path", + map[string]interface{}{ + "image": "image1", + "from": "alpine", + "sign": []map[string]interface{}{ + {"path": "/out/x.roothash", "after": "install"}, + {"path": "/out/x.roothash", "before": "setup"}, + }, + }, + ), ) }) diff --git a/pkg/config/sign.go b/pkg/config/sign.go new file mode 100644 index 0000000000..2efbad664a --- /dev/null +++ b/pkg/config/sign.go @@ -0,0 +1,43 @@ +package config + +import ( + "fmt" + "path" +) + +// Sign declares a file of the image whose detached PKCS#7 signature werf writes +// next to it as .p7s, right after the stage named by Before or After. +type Sign struct { + Path string + Before string + After string + + raw *rawSign +} + +func (c *Sign) GetRaw() interface{} { + return c.raw +} + +func (c *Sign) OutputPath() string { + return c.Path + ".p7s" +} + +func (c *Sign) validate() error { + switch { + case c.Path == "": + return newDetailedConfigError("`path: /PATH/TO/FILE` required for sign!", c.raw, c.raw.rawStapelImage.doc) + case !path.IsAbs(c.Path) || path.Clean(c.Path) != c.Path || c.Path == "/": + return newDetailedConfigError(fmt.Sprintf("invalid `path: %s` for sign: expected an absolute clean file path!", c.Path), c.raw, c.raw.rawStapelImage.doc) + case c.Before != "" && c.After != "": + return newDetailedConfigError("specify only one stage using `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc) + case c.Before == "" && c.After == "": + return newDetailedConfigError("stage is not specified with `before: install|setup` or `after: install|setup` for sign!", c.raw, c.raw.rawStapelImage.doc) + case c.Before != "" && checkInvalidRelation(c.Before): + return newDetailedConfigError(fmt.Sprintf("invalid stage `before: %s` for sign: expected install or setup!", c.Before), c.raw, c.raw.rawStapelImage.doc) + case c.After != "" && checkInvalidRelation(c.After): + return newDetailedConfigError(fmt.Sprintf("invalid stage `after: %s` for sign: expected install or setup!", c.After), c.raw, c.raw.rawStapelImage.doc) + default: + return nil + } +} diff --git a/pkg/config/stapel_image_base.go b/pkg/config/stapel_image_base.go index bc6a74014f..be9542485d 100644 --- a/pkg/config/stapel_image_base.go +++ b/pkg/config/stapel_image_base.go @@ -18,6 +18,7 @@ type StapelImageBase struct { Shell *Shell Mount []*Mount Import []*Import + Sign []*Sign Dependencies []*Dependency Secrets []Secret ImageSpec *ImageSpec diff --git a/pkg/config/werf_schema_test.go b/pkg/config/werf_schema_test.go index 7cde66e47f..170ecccafc 100644 --- a/pkg/config/werf_schema_test.go +++ b/pkg/config/werf_schema_test.go @@ -661,6 +661,40 @@ import: - from: builder before: beforeInstall add: /app +`), + Entry("sign without path", ` +image: app +from: alpine +sign: +- after: install +`), + Entry("sign without stage", ` +image: app +from: alpine +sign: +- path: /out/x +`), + Entry("sign with both before and after", ` +image: app +from: alpine +sign: +- path: /out/x + before: install + after: install +`), + Entry("sign with unknown stage", ` +image: app +from: alpine +sign: +- path: /out/x + after: beforeInstall +`), + Entry("sign with relative path", ` +image: app +from: alpine +sign: +- path: out/x + after: install `), Entry("import with empty from", ` image: app diff --git a/pkg/container_backend/buildah_image_reader.go b/pkg/container_backend/buildah_image_reader.go index 168d5cd8c3..d5216acf1a 100644 --- a/pkg/container_backend/buildah_image_reader.go +++ b/pkg/container_backend/buildah_image_reader.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "github.com/werf/logboek" ) @@ -30,7 +29,7 @@ func (backend *BuildahBackend) ReadFileFromImage(ctx context.Context, imageRef, } }() - data, err := os.ReadFile(filepath.Join(container.RootMount, path)) + data, err := readRegularFileFromRoot(container.RootMount, path) if err != nil { return nil, fmt.Errorf("read %s from image %q: %w", path, imageRef, err) } @@ -38,6 +37,24 @@ func (backend *BuildahBackend) ReadFileFromImage(ctx context.Context, imageRef, return data, nil } +// readRegularFileFromRoot reads path inside the mounted container root, following +// symlinks against that root rather than the host, and accepts regular files only, +// matching what the Docker reader yields for the same path. +func readRegularFileFromRoot(rootMount, path string) ([]byte, error) { + resolvedPath, err := resolveContainerRootPath(rootMount, path) + if err != nil { + return nil, err + } + info, err := os.Lstat(resolvedPath) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("not a regular file") + } + return os.ReadFile(resolvedPath) +} + // OpenImageReader is intentionally unsupported on the Buildah backend: the only caller is // the SBOM directory-scan flow, which rejects Buildah upfront (see the SBOM guard in the // conveyor), so a ContainerBackend backed by Buildah never reaches an image reader. diff --git a/pkg/container_backend/buildah_image_reader_test.go b/pkg/container_backend/buildah_image_reader_test.go new file mode 100644 index 0000000000..74bfb47a79 --- /dev/null +++ b/pkg/container_backend/buildah_image_reader_test.go @@ -0,0 +1,47 @@ +package container_backend + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("BuildahBackend ReadFileFromImage path resolution", func() { + It("reads a regular file by its container path", func() { + root := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(root, "out"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "out", "x"), []byte("content"), 0o644)).To(Succeed()) + + Expect(readRegularFileFromRoot(root, "/out/x")).To(Equal([]byte("content"))) + }) + + It("follows an absolute symlink against the container root, not the host", func() { + base := GinkgoT().TempDir() + root := filepath.Join(base, "root") + Expect(os.MkdirAll(filepath.Join(root, "out"), 0o755)).To(Succeed()) + Expect(os.MkdirAll(filepath.Join(root, "etc"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(root, "etc", "secret"), []byte("container"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(base, "secret"), []byte("host"), 0o644)).To(Succeed()) + Expect(os.Symlink("/etc/secret", filepath.Join(root, "out", "x"))).To(Succeed()) + Expect(os.Symlink("../../secret", filepath.Join(root, "out", "y"))).To(Succeed()) + + Expect(readRegularFileFromRoot(root, "/out/x")).To(Equal([]byte("container"))) + + _, err := readRegularFileFromRoot(root, "/out/y") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("rejects anything but a regular file", func() { + root := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(root, "out"), 0o755)).To(Succeed()) + Expect(os.Symlink("/nowhere", filepath.Join(root, "out", "dangling"))).To(Succeed()) + + _, err := readRegularFileFromRoot(root, "/out") + Expect(err).To(MatchError("not a regular file")) + + _, err = readRegularFileFromRoot(root, "/out/dangling") + Expect(err).To(MatchError(os.ErrNotExist)) + }) +}) diff --git a/schemas/werf.json b/schemas/werf.json index ec0bd99106..69988fb722 100644 --- a/schemas/werf.json +++ b/schemas/werf.json @@ -1506,6 +1506,41 @@ } ] }, + "sign": { + "description": "Signs a file the image produces at a user stage boundary and lays the detached PKCS#7 signature down next to it as `.p7s`. Exactly one of `before` or `after` must be set.\nhttps://werf.io/docs/latest/usage/build/stapel/imports.html#signing-files-inside-the-image", + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "description": "The absolute path of the file to sign.", + "type": "string", + "pattern": "^/" + }, + "before": { + "description": "The stage before which to sign the file.", + "$ref": "#/definitions/stageName" + }, + "after": { + "description": "The stage after which to sign the file.", + "$ref": "#/definitions/stageName" + } + }, + "oneOf": [ + { + "required": [ + "before" + ] + }, + { + "required": [ + "after" + ] + } + ] + }, "stapelImage": { "description": "Stapel image section: builds an image with werf's own assembly instructions. Define as many as you need.\nhttps://werf.io/docs/latest/usage/build/stapel/base.html", "type": "object", @@ -1573,6 +1608,13 @@ "$ref": "#/definitions/import" } }, + "sign": { + "description": "Files to sign inside the image.\nhttps://werf.io/docs/latest/usage/build/stapel/imports.html#signing-files-inside-the-image", + "type": "array", + "items": { + "$ref": "#/definitions/sign" + } + }, "dependencies": { "description": "Dependency images for the current image.", "type": "array", diff --git a/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.crt b/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.crt new file mode 100644 index 0000000000..a4bb815593 --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICyTCCAbGgAwIBAgIBBzANBgkqhkiG9w0BAQsFADAeMRwwGgYDVQQDExN3ZXJm +LWUyZS1zaWduLWZpbGVzMB4XDTI2MTAxNDEzMDU1NloXDTM2MTAxNDE0MDU1Nlow +HjEcMBoGA1UEAxMTd2VyZi1lMmUtc2lnbi1maWxlczCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAODtpy6et9IHDZVN4Fl/o0qou1EGQUKLphFPha4a93PD +/qFerzeqcnIJst4QzXHah0SgUu3y+GtZuBexTAp9jOEGxx4LwoqllPezp/o5oUNS +49RU7zFfo/4GtIGi5+asMDFLfFsMIrGXjaJLykhVsanFgZx527B3bYC1l0qgoNKP +TWfbxTTU2zG+H1dv0SG0bc19Z1qsJSnmemSXrkeQ47c7U+CU8acNA6opg4UOzPfe +r8O1GTT9wYK9MN/Aa7fAJ9s0Mnbb+1HrBO6XqokMS/v55daN+l93dn2d9V+kCbXx +z27oMqPRb1GH4yftg3BO0YCVj3YAqUeNiJ4qqKkJCmECAwEAAaMSMBAwDgYDVR0P +AQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUAA4IBAQA3jmuzx5v0ELZzWLaU9+ltAw/l +YkMC4C3dhfG/JMsqV9c0jkSy6rNuC4IEONiccqkTP14sCrenzP/wGwMS3zkre0a/ ++Xu/s80A95A3cXpXBxG3FOGGXfkL6Mqx0KQsIAolYC/o0vLDA0b3508u2C2rpiRB +smOyM97pHJ9xDGKhxlx+IsFbZr9rD8zHTfdX+607t8NqCWGHzTKPuUfueDsgazUD +kqpu+wWi4aF42jxSk+r57wvVI1eEzWJr/EkqJ/7Vg/5FXd+3CYQ60ui9+ty/dyRR +SCeWEhs0e5ftQ0uI2IYuAtYsZQmtAT0f8360US1X/x3S1Iu6/kSF1Ic0B4Ni +-----END CERTIFICATE----- diff --git a/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.key b/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.key new file mode 100644 index 0000000000..1fecb3d283 --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/keys/rsa_2048.pem.key @@ -0,0 +1,41 @@ +-----BEGIN PRIVATE KEY----- +eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 +OCwicCI6MX0sInNhbHQiOiJDcXh5Ky93Y3g2RDZXUEoxZy84c09tVHFRakJiekV0 +cG1YdFhhaXJOODEwPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 +Iiwibm9uY2UiOiIxTVY5aTB3UzJKZk9Vc3JzYnBITnk2OUkxUEUwcElYNCJ9LCJj +aXBoZXJ0ZXh0Ijoiak0vVjFOVXVNdXVaVC9NUW5wK2IvN2FzSHlNQXZvUUdnWXA1 +b2hESTFTMGVQQUhvVnptOXFTOWxheWphRDFQWXVCVzZLZjJINUs5eFc5SXAva01Z +RTZKYisvVkZ2SFYwS3REZG12TmUxSVVLM1pORndYYTZoRFpVckVaQmhoQWYwM0dZ +djYvY0NWdDBKcURvditMWkNnanBxUlkvaGxZQmFYaFBEd3NKbUI4ZEJRb3RuSjlS +dngxWWFUWjByZnlpVE1RVGJBVjUrTWljbjduNDNSNGxRamVyWXNQR0l0OHM5SHdt +SmgwcnhHRkpNTzlGNjdheTNGaC8wYkczS3BjRUNRSEhnUDhKQllIL2FVR01GZm1i +U2doaFFoTEt2ZlV4SWJ0bE0rM2JzbDh3am10Z3B4MHYxdHFNM3pYZ042eFErYzlV +ZmhURTk3OHIvYW9SNUN0WU9mUEdsaVJlUTBudzhYTVhQTWNhemxHdGM4ZHlmUit3 +Ym54NkZoQVkyaXZ6UXVTRmtzVU5vb28xTDl1UitWcDkxRWRnblZWbHA4WlBmMTEw +a2hiNzg5S3ZHc1NWZlJJZnIvVEFqSklESFgrdjhadkxPdThvOE8raVBlaVVqK3l2 +T1B0QU81cTFVaVc2WUI3YXF3ZHVpWC9KWVRZRkhFQ3VCdzFjbkNMRXZ4MUZvZUh0 +OUQ2bU0zc3Z5c3hYUDZKb1JxeVd3SS9xbkpVWXU2SyttbWF2andCZXJUK3NaOUc5 +SkwxbW9SaGg0T2R2d0pRRjNnOHdpQ1NFTm9nN3Q1RWxiTy9ZcFYzczhrUDEzZlBt +ZW1yT3lKM3lOZXBMSXgvSGxMcHBzQS9BOFNqUERMc1QrdW9RcWVYaXNYNXpnbFRI +ellHL2xTU0U5NVNtZmxWLzRYRVNtNGJVZDNwbGowUjdtb01zVnFwM0Q0MTNUUzlY +NjdTN2FnM05VSnphdzVDemhLaDBLdXRSYmozTHVYZlJHRGRYdE4yWkNHWnpiZ0o2 +ZzJJWFprQ054VTRaUnMzank4QTNmQ1gxQ1dYSnpKQVlzRmVxUXlmZGIxb1lxTkVQ +SUpjOG9ZOFF1TmlJVDRIaEd2U2xBbFI5VWpmc3RCTE4vaUlTcnRjSmlVM3F2Smkv +dmdtSDVZRXpEZk5WaWZDMjhSR0xRdktGSldoeUlkRDVKZHhmQzhHMm9ySXFBY0xE +QjR2QnRnQ3pySHZiUUh3VHJSTXEzeVFYY3BsWDQ3V09yRnRCcDhaaVJMOVhjSGU1 +K1BrU0o2VysrRVJ0dnQ4NGVSbUxKWXp4NnNlQVQzQ0NPMlJtOGhQVG8zNW9aMm9X +cVRNQTV6Q1IzOGJ6ODlzZDI2SnFob0pJbFloRlh1ZFFUaE9UbkRjc0lPbm84Vjht +RU4zeUd4eUZNQit2VjZPMlNGNVdyeFdDUXh4WjdGejVrak9HYk9GTHgzaDl6L2Zm +N2UrblpMZUIyZ2lUcUw2UU5oT0taSFN4WjR6eXdDRkFvUVBjYUZsWjY0aVFXQWlU +VlNVaXB1WmVrRWU1UTVwQk5sVEJMWStCWGYyT1pjMGJlMDBXZUxpNDA4Q1ZzTjNy +dFV6dko1dTRjK05GWjF4TnVwSWZrTTE2UFJiZFl4T1lERm9QaGRlN3VGcnJMa2hl +L2FuSm96Z2NXOGwwY3gzeUZnOGwzWjJ6RnF4bFM4cUZndXFGVkR0SWZMVUFTWFAv +cjJSUmhMaWQ2c2Z1MG5iRUxDVkhobXVRaGRTOG5kekJHaWVYRGhucVdpL2hEajl3 +SlQ1YWdpT255bWo1OHRWSE04UXh3ZDA0b0VtSTZJRVBabnFjclI1VjFMQnpGdXJm +YUNYYlF6bVovZktUaGt0OXl2VHF2Q09vL0IvNHgrK083am5DZmp0OFErMnUzemFG +M1JNbTcvbkowNzFRTVZoNUp1QUJpbzNBekNDN1dGWVBhZkh0bnErN1pBVEFNelJM +WUcxbzhlUnE1SnhVSnp2a29sQ3BiVW5mMldpdVh2bmRBclZPQjREajV3ak0yM0xS +OTEybVdCREF1dFZ1Y0k4TEhDcEhKYlRMcWdLaytZUURvakU4K1VzWGVOaks0cGRM +RnNvc2Uwa09OT2ZpTFJscWpKYUJyWWVkeXZjb3h5WU1hM2NFdEt1b0t6dUsvUGlv +aFJLczV5VEtqZnk5TTQzeWVrVXNJOFJvZEE9PSJ9 +-----END PRIVATE KEY----- diff --git a/test/e2e/build/_fixtures/sign_files/state/werf.yaml b/test/e2e/build/_fixtures/sign_files/state/werf.yaml new file mode 100644 index 0000000000..da8318c2c0 --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/state/werf.yaml @@ -0,0 +1,14 @@ +project: werf-test-e2e-sign-files +configVersion: 1 +--- +image: sysext +from: alpine:3.20 +shell: + install: + - mkdir -p /out + - printf '%s' 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b > /out/ext.roothash + setup: + - test -s /out/ext.roothash.p7s +sign: +- path: /out/ext.roothash + after: install diff --git a/test/e2e/build/_fixtures/sign_files/state_sbom/werf.yaml b/test/e2e/build/_fixtures/sign_files/state_sbom/werf.yaml new file mode 100644 index 0000000000..08309be6ee --- /dev/null +++ b/test/e2e/build/_fixtures/sign_files/state_sbom/werf.yaml @@ -0,0 +1,22 @@ +project: werf-test-e2e-sign-files-sbom +configVersion: 1 +build: + platform: + - linux/amd64 + sbom: + enable: true + standard: cyclonedx@1.6 +--- +image: sysext +from: registry.deckhouse.io/container-factory@sha256:7aac8d97a91ac233b19111aaad9d7bba12b975e620cfa9287b97e0003e4d0a71 +shell: + install: + - mkdir -p /out + - printf '%s' 3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b > /out/ext.roothash + - command -v ping >/dev/null || { echo 'network probe tool is missing' >&2; exit 1; } + - if ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then echo 'network is reachable' >&2; exit 1; fi + setup: + - test -s /out/ext.roothash.p7s +sign: +- path: /out/ext.roothash + after: install diff --git a/test/e2e/build/sign_files_test.go b/test/e2e/build/sign_files_test.go new file mode 100644 index 0000000000..8c35fbbe95 --- /dev/null +++ b/test/e2e/build/sign_files_test.go @@ -0,0 +1,172 @@ +package e2e_build_test + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + _ "embed" + "encoding/base64" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/smallstep/pkcs7" + + elfTar "github.com/werf/werf/v3/pkg/signature/elf/tar" + "github.com/werf/werf/v3/test/pkg/report" + "github.com/werf/werf/v3/test/pkg/utils" + "github.com/werf/werf/v3/test/pkg/werf" +) + +const ( + signFilesContent = "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b" + signFilesPath = "/out/ext.roothash" +) + +var ( + //go:embed _fixtures/sign_files/keys/rsa_2048.pem.key + signFilesKeyData []byte + //go:embed _fixtures/sign_files/keys/rsa_2048.pem.crt + signFilesCertData []byte +) + +type signFilesTestOptions struct { + setupEnvOptions +} + +var _ = Describe("sign directive", Label("e2e", "sign-files", "simple"), func() { + signArgs := []string{ + "--sign-files-key", base64.StdEncoding.EncodeToString(signFilesKeyData), + "--sign-files-cert", base64.StdEncoding.EncodeToString(signFilesCertData), + } + + // readSignedFiles returns the signed file and its .p7s out of the built image. + readSignedFiles := func(imageName string) (content, p7s []byte) { + img := loadImageFromRegistry(imageName) + utils.ForEachImageFile(SuiteData.TmpDir, img, func(header *elfTar.Header, tmpLocalPath string) { + switch "/" + strings.TrimPrefix(header.Name, "/") { + case signFilesPath: + var err error + content, err = os.ReadFile(tmpLocalPath) + Expect(err).To(Succeed()) + case signFilesPath + ".p7s": + var err error + p7s, err = os.ReadFile(tmpLocalPath) + Expect(err).To(Succeed()) + } + }) + Expect(content).To(Equal([]byte(signFilesContent))) + Expect(p7s).NotTo(BeEmpty(), "the image must carry %s.p7s", signFilesPath) + return content, p7s + } + + // expectSystemdProfile asserts the .p7s is what systemd-keyutil would have written: + // a detached SignedData, no authenticated attributes, the leaf embedded, an + // RSASSA-PKCS1-v1_5/SHA-256 signature over the content itself. + expectSystemdProfile := func(content, p7s []byte) { + p7, err := pkcs7.Parse(p7s) + Expect(err).To(Succeed()) + Expect(p7.Content).To(BeEmpty()) + Expect(p7.Signers).To(HaveLen(1)) + Expect(p7.Signers[0].AuthenticatedAttributes).To(BeEmpty()) + + certs, err := cryptoutils.UnmarshalCertificatesFromPEM(signFilesCertData) + Expect(err).To(Succeed()) + Expect(p7.Certificates).To(HaveLen(1)) + Expect(p7.Certificates[0].Raw).To(Equal(certs[0].Raw)) + + digest := sha256.Sum256(content) + Expect(rsa.VerifyPKCS1v15(certs[0].PublicKey.(*rsa.PublicKey), crypto.SHA256, digest[:], p7.Signers[0].EncryptedDigest)).To(Succeed()) + + if _, err := exec.LookPath("openssl"); err != nil { + return + } + dir := GinkgoT().TempDir() + contentPath := filepath.Join(dir, "ext.roothash") + p7sPath := contentPath + ".p7s" + Expect(os.WriteFile(contentPath, content, 0o600)).To(Succeed()) + Expect(os.WriteFile(p7sPath, p7s, 0o600)).To(Succeed()) + out, err := exec.Command("openssl", "smime", "-verify", "-inform", "der", "-in", p7sPath, "-content", contentPath, "-noverify", "-out", os.DevNull).CombinedOutput() + Expect(err).To(Succeed(), string(out)) + } + + DescribeTable("signs the declared file after the named stage", + func(ctx SpecContext, testOpts signFilesTestOptions) { + setupEnv(testOpts.setupEnvOptions) + + SuiteData.InitTestRepo(ctx, "repo0", "sign_files/state") + werfProject := report.NewProjectWithReport(werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath("repo0"))) + + By("building with the files signer") + buildOut, buildReport := werfProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("report0.json"), &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: signArgs}}) + Expect(buildOut).To(ContainSubstring("Building stage sysext/install")) + Expect(buildOut).To(ContainSubstring("Signing " + signFilesPath)) + Expect(buildOut).To(ContainSubstring("Building stage sysext/signFilesAfterInstall")) + Expect(buildOut).To(ContainSubstring("Building stage sysext/setup")) + + By("verifying the signature in the image") + expectSystemdProfile(readSignedFiles(buildReport.Images["sysext"].DockerImageName)) + + By("rebuilding with the same key reuses the stage") + rebuildOut := werfProject.Build(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ExtraArgs: signArgs}}) + Expect(rebuildOut).To(ContainSubstring("Use previously built image for sysext")) + Expect(rebuildOut).NotTo(ContainSubstring("Building stage")) + }, + backendEntry("with repo using Docker", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "docker", + WithLocalRepo: true, + }}), + backendEntry("with repo using Native Rootless", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "native-rootless", + WithLocalRepo: true, + }}), + ) + + DescribeTable("keeps the build offline under build.sbom.enable and still signs", + func(ctx SpecContext, testOpts signFilesTestOptions) { + setupEnv(testOpts.setupEnvOptions) + + SuiteData.InitTestRepo(ctx, "repo0", "sign_files/state_sbom") + werfProject := report.NewProjectWithReport(werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath("repo0"))) + + buildOut, buildReport := werfProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("report0.json"), &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: signArgs}}) + Expect(buildOut).To(ContainSubstring("Network is disabled for shell stages")) + Expect(buildOut).To(ContainSubstring("Building stage sysext/signFilesAfterInstall")) + + expectSystemdProfile(readSignedFiles(buildReport.Images["sysext"].DockerImageName)) + }, + backendEntry("with repo using Docker", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "docker", + WithLocalRepo: true, + }}), + ) + + DescribeTable("refuses to build without a key unless signing is switched off", + func(ctx SpecContext, testOpts signFilesTestOptions) { + setupEnv(testOpts.setupEnvOptions) + + SuiteData.InitTestRepo(ctx, "repo0", "sign_files/state") + werfProject := werf.NewProject(SuiteData.WerfBinPath, SuiteData.GetTestRepoPath("repo0")) + + By("no key: the build stops before any stage") + out, err := werfProject.BuildWithErr(ctx, &werf.BuildOptions{}) + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("images sysext declare files to sign")) + Expect(out).NotTo(ContainSubstring("Building stage")) + + By("--sign-files=false: the image is built without the signature") + out, err = werfProject.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--sign-files=false"}}}) + Expect(err).To(HaveOccurred(), "the setup stage asserts the .p7s is present, so it must fail") + Expect(out).NotTo(ContainSubstring("signFilesAfterInstall")) + Expect(out).To(ContainSubstring("Building stage sysext/setup")) + }, + backendEntry("with repo using Docker", signFilesTestOptions{setupEnvOptions: setupEnvOptions{ + ContainerBackendMode: "docker", + WithLocalRepo: true, + }}), + ) +})