Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8191fad
feat(sign): add zarf package sign command
brandtkeller Oct 21, 2025
2fbae57
feat(sign): add testing for signing
brandtkeller Oct 21, 2025
9880886
feat(sign): support for cosign sign-blob
brandtkeller Oct 21, 2025
7669a8b
feat(sign): add support for oci to oci signing
brandtkeller Oct 22, 2025
6f5399a
feat(sign): add build signing metadata
brandtkeller Oct 22, 2025
e5685fc
feat(schema): generate updated schema
brandtkeller Oct 22, 2025
0fe749e
fix(schema): update to support boolptr
brandtkeller Oct 22, 2025
c7c0f63
feat(sign): consolidate sign logic to CLI
brandtkeller Oct 22, 2025
8752f64
feat(sign): layout SignPackage unit testing
brandtkeller Oct 22, 2025
1dc1c11
fix(sign): update testing to support new build data
brandtkeller Oct 23, 2025
b832a77
fix(sign): update testing to support windows filepaths
brandtkeller Oct 23, 2025
26d04ab
fix(sign): remove test with variance across OS
brandtkeller Oct 23, 2025
3234fe6
Merge branch 'main' of github.com:zarf-dev/zarf into 3959_package_sign
brandtkeller Oct 23, 2025
6321683
fix(sign): revert verify logic for future PR
brandtkeller Oct 23, 2025
de13320
feat(sign): add signed build data and e2e test
brandtkeller Oct 23, 2025
cb7c656
Merge branch 'main' of github.com:zarf-dev/zarf into 3959_package_sign
brandtkeller Oct 23, 2025
b27c755
feat(sign): package signed getter and more atomic file replacement
brandtkeller Oct 27, 2025
c10d1a1
chore(docs): move doccomment
brandtkeller Oct 27, 2025
edb3284
Merge branch 'main' of github.com:zarf-dev/zarf into 3959_package_sign
brandtkeller Oct 27, 2025
ca9b419
fix(sign): review feedback
brandtkeller Oct 28, 2025
726a201
Merge branch 'main' of github.com:zarf-dev/zarf into 3959_package_sign
brandtkeller Oct 28, 2025
2ba210e
fix(cosign): update options to embed cosign options
brandtkeller Oct 29, 2025
c6908d6
fix(unit): update error message
brandtkeller Oct 29, 2025
73022f5
feat(sign): migrate IsSigned to package layout
brandtkeller Oct 29, 2025
2dac1a2
fix(sign): set signed build data by default
brandtkeller Oct 29, 2025
48f1820
Merge branch 'main' into 3959_package_sign
brandtkeller Oct 29, 2025
049b00a
fix(cosign): move cosign utils to internal
brandtkeller Oct 31, 2025
63fa773
fix(sign): rework api options requirements
brandtkeller Oct 31, 2025
04f254e
Merge branch 'main' of github.com:zarf-dev/zarf into 3959_package_sign
brandtkeller Oct 31, 2025
dbf8c92
chore(docs): generate docs updates
brandtkeller Nov 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/src/content/docs/commands/zarf_package.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ Zarf package commands for creating, deploying, and inspecting packages
* [zarf package publish](/commands/zarf_package_publish/) - Publishes a Zarf package to a remote registry
* [zarf package pull](/commands/zarf_package_pull/) - Pulls a Zarf package from a remote registry and save to the local file system
* [zarf package remove](/commands/zarf_package_remove/) - Removes a Zarf package that has been deployed already (runs offline)
* [zarf package sign](/commands/zarf_package_sign/) - Signs an existing Zarf package

73 changes: 73 additions & 0 deletions site/src/content/docs/commands/zarf_package_sign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: zarf package sign
description: Zarf CLI command reference for <code>zarf package sign</code>.
tableOfContents: false
---

<!-- Page generated by Zarf; DO NOT EDIT -->

## zarf package sign

Signs an existing Zarf package

### Synopsis

Signs an existing Zarf package with a private key. The package can be a local tarball or pulled from an OCI registry. The signature is created by signing the zarf.yaml file and does not modify the package checksums.

```
zarf package sign PACKAGE_SOURCE [flags]
```

### Examples

```

# Sign an unsigned package
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem

# Re-sign with a new key (overwrite existing signature)
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./new-key.pem --overwrite

# Sign a package from an OCI registry and output to local directory
$ zarf package sign oci://ghcr.io/my-org/my-package:1.0.0 --signing-key ./private-key.pem --output ./signed/

# Sign a package and publish directly to OCI registry
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem --output oci://ghcr.io/my-org/signed-packages

# Sign with a cloud KMS key
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms://alias/my-signing-key

```

### Options

```
-h, --help help for sign
-k, --key string Public key to verify the existing signature before re-signing (optional)
--oci-concurrency int Number of concurrent layer operations when pulling or pushing images or packages to/from OCI registries. (default 6)
-o, --output string Output destination for the signed package. Can be a local directory or an OCI registry URL (oci://). Default: same directory as source package for files, current directory for OCI sources
--overwrite Overwrite an existing signature if the package is already signed
--retries int Number of retries to perform for Zarf operations like git/image pushes (default 3)
--signing-key string Private key for signing packages. Accepts either a local file path or a Cosign-supported key provider (awskms://, gcpkms://, azurekms://, hashivault://)
--signing-key-pass string Password for encrypted private key
--skip-signature-validation Skip validating the signature of the Zarf package
```

### Options inherited from parent commands

```
-a, --architecture string Architecture for OCI images and Zarf packages
--features stringToString [ALPHA] Provide a comma-separated list of feature names to bools to enable or disable. Ex. --features "foo=true,bar=false,baz=true" (default [])
--insecure-skip-tls-verify Skip checking server's certificate for validity. This flag should only be used if you have a specific reason and accept the reduced security posture.
--log-format string Select a logging format. Defaults to 'console'. Valid options are: 'console', 'json', 'dev'. (default "console")
-l, --log-level string Log level when running Zarf. Valid options are: warn, info, debug, trace (default "info")
--no-color Disable terminal color codes in logging and stdout prints.
--plain-http Force the connections over HTTP instead of HTTPS. This flag should only be used if you have a specific reason and accept the reduced security posture.
--tmpdir string Specify the temporary directory to use for intermediate files
--zarf-cache string Specify the location of the Zarf cache directory (default "~/.zarf-cache")
```

### SEE ALSO

* [zarf package](/commands/zarf_package/) - Zarf package commands for creating, deploying, and inspecting packages

10 changes: 10 additions & 0 deletions src/api/v1alpha1/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ func (pkg ZarfPackage) AllowsNamespaceOverride() bool {
return true
}

// IsSigned returns whether a Zarf package is signed.
func (pkg ZarfPackage) IsSigned() bool {
if pkg.Build.Signed == nil {
return false
}
return *pkg.Build.Signed
}

// Variable represents a variable that has a value set programmatically
type Variable struct {
// The name to be used for the variable
Expand Down Expand Up @@ -271,6 +279,8 @@ type ZarfBuildData struct {
LastNonBreakingVersion string `json:"lastNonBreakingVersion,omitempty"`
// The flavor of Zarf used to build this package.
Flavor string `json:"flavor,omitempty"`
// Whether this package was signed
Signed *bool `json:"signed,omitempty"`
}

// ZarfValues imports package-level values files and validation.
Expand Down
169 changes: 169 additions & 0 deletions src/cmd/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/defenseunicorns/pkg/helpers/v2"
goyaml "github.com/goccy/go-yaml"
"github.com/sigstore/cosign/v3/pkg/cosign"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zarf-dev/zarf/src/internal/packager/images"
Expand Down Expand Up @@ -60,6 +61,7 @@ func newPackageCommand() *cobra.Command {
cmd.AddCommand(newPackageListCommand())
cmd.AddCommand(newPackagePublishCommand(v))
cmd.AddCommand(newPackagePullCommand(v))
cmd.AddCommand(newPackageSignCommand(v))

return cmd
}
Expand Down Expand Up @@ -1536,6 +1538,173 @@ func (o *packagePullOptions) run(cmd *cobra.Command, args []string) error {
return nil
}

type packageSignOptions struct {
signingKeyPath string
signingKeyPassword string
publicKeyPath string
skipSignatureValidation bool
overwrite bool
output string
ociConcurrency int
retries int
}

func newPackageSignCommand(v *viper.Viper) *cobra.Command {
o := &packageSignOptions{}

cmd := &cobra.Command{
Use: "sign PACKAGE_SOURCE",
Aliases: []string{"s"},
Args: cobra.ExactArgs(1),
Short: lang.CmdPackageSignShort,
Long: lang.CmdPackageSignLong,
Example: lang.CmdPackageSignExample,
RunE: o.run,
}

cmd.Flags().StringVar(&o.signingKeyPath, "signing-key", v.GetString(VPkgSignSigningKey), lang.CmdPackageSignFlagSigningKey)
cmd.Flags().StringVar(&o.signingKeyPassword, "signing-key-pass", v.GetString(VPkgSignSigningKeyPassword), lang.CmdPackageSignFlagSigningKeyPass)
cmd.Flags().StringVarP(&o.output, "output", "o", v.GetString(VPkgSignOutput), lang.CmdPackageSignFlagOutput)
cmd.Flags().BoolVar(&o.overwrite, "overwrite", v.GetBool(VPkgSignOverwrite), lang.CmdPackageSignFlagOverwrite)
cmd.Flags().StringVarP(&o.publicKeyPath, "key", "k", v.GetString(VPkgPublicKey), lang.CmdPackageSignFlagKey)
cmd.Flags().BoolVar(&o.skipSignatureValidation, "skip-signature-validation", false, lang.CmdPackageFlagSkipSignatureValidation)
cmd.Flags().IntVar(&o.ociConcurrency, "oci-concurrency", v.GetInt(VPkgOCIConcurrency), lang.CmdPackageFlagConcurrency)
cmd.Flags().IntVar(&o.retries, "retries", v.GetInt(VPkgRetries), lang.CmdPackageFlagRetries)

return cmd
}

func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
l := logger.From(ctx)
packageSource := args[0]

if o.signingKeyPath == "" {
return errors.New("--signing-key is required")
}

cachePath, err := getCachePath(ctx)
if err != nil {
return err
}

// Determine output destination
outputDest := o.output
if outputDest == "" {
// Default to the directory containing the source package
if helpers.IsOCIURL(packageSource) {
// For OCI sources, use current working directory
wd, err := os.Getwd()
if err != nil {
return err
}
outputDest = wd
} else {
// For file sources, use the same directory as the source
outputDest = filepath.Dir(packageSource)
}
}

// Load the package
loadOpts := packager.LoadOptions{
PublicKeyPath: o.publicKeyPath,
SkipSignatureValidation: o.skipSignatureValidation,
Filter: filters.Empty(),
Architecture: config.GetArch(),
OCIConcurrency: o.ociConcurrency,
RemoteOptions: defaultRemoteOptions(),
CachePath: cachePath,
}

l.Info("loading package", "source", packageSource)
pkgLayout, err := packager.LoadPackage(ctx, packageSource, loadOpts)
if err != nil {
return fmt.Errorf("unable to load package: %w", err)
}
defer func() {
if cleanupErr := pkgLayout.Cleanup(); cleanupErr != nil {
l.Warn("failed to cleanup package layout", "error", cleanupErr)
}
}()

// Check for existing signature and handle overwrite logic
sigPath := filepath.Join(pkgLayout.DirPath(), layout.Signature)
_, err = os.Stat(sigPath)
sigExists := err == nil

if sigExists && !o.overwrite {
return errors.New("package is already signed, use --overwrite to re-sign")
}

if sigExists && o.overwrite {
l.Info("removing existing signature for re-signing")
if err := os.Remove(sigPath); err != nil {
return fmt.Errorf("failed to remove old signature: %w", err)
}
}

// Sign the package
l.Info("signing package with provided key")

passFunc := cosign.PassFunc(func(_ bool) ([]byte, error) {
return []byte(o.signingKeyPassword), nil
})

// Here we will merge future sign options
signOpts := utils.DefaultSignBlobOptions()
signOpts.KeyRef = o.signingKeyPath
signOpts.PassFunc = passFunc

err = pkgLayout.SignPackage(ctx, signOpts)
if err != nil {
return fmt.Errorf("failed to sign package: %w", err)
}

// Handle output - OCI or local file
if helpers.IsOCIURL(outputDest) {
l.Info("publishing signed package to OCI registry", "destination", outputDest)

// Parse the OCI reference
trimmed := strings.TrimPrefix(outputDest, helpers.OCIURLPrefix)
parts := strings.Split(trimmed, "/")
dstRef := registry.Reference{
Registry: parts[0],
Repository: strings.Join(parts[1:], "/"),
}

if err := dstRef.ValidateRegistry(); err != nil {
return fmt.Errorf("invalid OCI registry URL: %w", err)
}

// Publish the signed package to OCI
publishOpts := packager.PublishPackageOptions{
OCIConcurrency: o.ociConcurrency,
SigningKeyPath: "", // Already signed, don't re-sign - maybe remove?
SigningKeyPassword: "",
Retries: o.retries,
RemoteOptions: defaultRemoteOptions(),
}

pubRef, err := packager.PublishPackage(ctx, pkgLayout, dstRef, publishOpts)
if err != nil {
return fmt.Errorf("failed to publish signed package to OCI: %w", err)
}

l.Info("package signed and published successfully", "reference", pubRef.String())
return nil
}

// Archive to local directory
l.Info("archiving signed package to local directory", "directory", outputDest)
signedPath, err := pkgLayout.Archive(ctx, outputDest, 0)
if err != nil {
return fmt.Errorf("failed to archive signed package: %w", err)
}

l.Info("package signed successfully", "path", signedPath)
return nil
}

func choosePackage(ctx context.Context, args []string) (string, error) {
if len(args) > 0 {
return args[0], nil
Expand Down
7 changes: 7 additions & 0 deletions src/cmd/viper.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ const (
VPkgPublishSigningKeyPassword = "package.publish.signing_key_password"
VPkgPublishRetries = "package.publish.retries"

// Package sign config keys

VPkgSignSigningKey = "package.sign.signing_key"
VPkgSignSigningKeyPassword = "package.sign.signing_key_password"
VPkgSignOutput = "package.sign.output"
VPkgSignOverwrite = "package.sign.overwrite"

// Package pull config keys

VPkgPullOutputDir = "package.pull.output_directory"
Expand Down
24 changes: 24 additions & 0 deletions src/config/lang/english.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,30 @@ $ zarf package publish ./path/to/dir oci://my-registry.com/my-namespace
CmdPackagePublishFlagConfirm = "Confirms package publish without prompting. Skips prompt for the signing key password"
CmdPackagePublishFlagFlavor = "The flavor of components to include in the resulting package. The flavor will be appended to the package tag"

CmdPackageSignShort = "Signs an existing Zarf package"
CmdPackageSignLong = "Signs an existing Zarf package with a private key. The package can be a local tarball or pulled from an OCI registry. The signature is created by signing the zarf.yaml file and does not modify the package checksums."
CmdPackageSignExample = `
# Sign an unsigned package
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem

# Re-sign with a new key (overwrite existing signature)
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./new-key.pem --overwrite

# Sign a package from an OCI registry and output to local directory
$ zarf package sign oci://ghcr.io/my-org/my-package:1.0.0 --signing-key ./private-key.pem --output ./signed/

# Sign a package and publish directly to OCI registry
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem --output oci://ghcr.io/my-org/signed-packages

# Sign with a cloud KMS key
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms://alias/my-signing-key
`
CmdPackageSignFlagSigningKey = "Private key for signing packages. Accepts either a local file path or a Cosign-supported key provider (awskms://, gcpkms://, azurekms://, hashivault://)"
CmdPackageSignFlagSigningKeyPass = "Password for encrypted private key"
CmdPackageSignFlagOutput = "Output destination for the signed package. Can be a local directory or an OCI registry URL (oci://). Default: same directory as source package for files, current directory for OCI sources"
CmdPackageSignFlagOverwrite = "Overwrite an existing signature if the package is already signed"
CmdPackageSignFlagKey = "Public key to verify the existing signature before re-signing (optional)"

CmdPackagePullShort = "Pulls a Zarf package from a remote registry and save to the local file system"
CmdPackagePullExample = `
# Pull a package matching the current architecture
Expand Down
Loading
Loading