Skip to content

Commit 02e4a3f

Browse files
authored
feat!(sign): add package sign command (#4301)
Signed-off-by: Brandt Keller <brandt.keller@defenseunicorns.com>
1 parent 23bb6b1 commit 02e4a3f

File tree

16 files changed

+1150
-96
lines changed

16 files changed

+1150
-96
lines changed

site/src/content/docs/commands/zarf_package.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,5 @@ Zarf package commands for creating, deploying, and inspecting packages
4141
* [zarf package publish](/commands/zarf_package_publish/) - Publishes a Zarf package to a remote registry
4242
* [zarf package pull](/commands/zarf_package_pull/) - Pulls a Zarf package from a remote registry and save to the local file system
4343
* [zarf package remove](/commands/zarf_package_remove/) - Removes a Zarf package that has been deployed already (runs offline)
44+
* [zarf package sign](/commands/zarf_package_sign/) - Signs an existing Zarf package
4445

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
title: zarf package sign
3+
description: Zarf CLI command reference for <code>zarf package sign</code>.
4+
tableOfContents: false
5+
---
6+
7+
<!-- Page generated by Zarf; DO NOT EDIT -->
8+
9+
## zarf package sign
10+
11+
Signs an existing Zarf package
12+
13+
### Synopsis
14+
15+
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.
16+
17+
```
18+
zarf package sign PACKAGE_SOURCE [flags]
19+
```
20+
21+
### Examples
22+
23+
```
24+
25+
# Sign an unsigned package
26+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem
27+
28+
# Re-sign with a new key (overwrite existing signature)
29+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./new-key.pem --overwrite
30+
31+
# Sign a package from an OCI registry and output to local directory
32+
$ zarf package sign oci://ghcr.io/my-org/my-package:1.0.0 --signing-key ./private-key.pem --output ./signed/
33+
34+
# Sign a package and publish directly to OCI registry
35+
$ 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
36+
37+
# Sign with a cloud KMS key
38+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms://alias/my-signing-key
39+
40+
```
41+
42+
### Options
43+
44+
```
45+
-h, --help help for sign
46+
-k, --key string Public key to verify the existing signature before re-signing (optional)
47+
--oci-concurrency int Number of concurrent layer operations when pulling or pushing images or packages to/from OCI registries. (default 6)
48+
-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
49+
--overwrite Overwrite an existing signature if the package is already signed
50+
--retries int Number of retries to perform for Zarf operations like git/image pushes (default 3)
51+
--signing-key string Private key for signing packages. Accepts either a local file path or a Cosign-supported key provider (awskms://, gcpkms://, azurekms://, hashivault://)
52+
--signing-key-pass string Password for encrypted private key
53+
```
54+
55+
### Options inherited from parent commands
56+
57+
```
58+
-a, --architecture string Architecture for OCI images and Zarf packages
59+
--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 [])
60+
--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.
61+
--log-format string Select a logging format. Defaults to 'console'. Valid options are: 'console', 'json', 'dev'. (default "console")
62+
-l, --log-level string Log level when running Zarf. Valid options are: warn, info, debug, trace (default "info")
63+
--no-color Disable terminal color codes in logging and stdout prints.
64+
--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.
65+
--tmpdir string Specify the temporary directory to use for intermediate files
66+
--zarf-cache string Specify the location of the Zarf cache directory (default "~/.zarf-cache")
67+
```
68+
69+
### SEE ALSO
70+
71+
* [zarf package](/commands/zarf_package/) - Zarf package commands for creating, deploying, and inspecting packages
72+

src/api/v1alpha1/package.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ type ZarfBuildData struct {
269269
DifferentialMissing []string `json:"differentialMissing,omitempty"`
270270
// The flavor of Zarf used to build this package.
271271
Flavor string `json:"flavor,omitempty"`
272+
// Whether this package was signed
273+
Signed *bool `json:"signed,omitempty"`
272274
// Requirements for specific package operations.
273275
VersionRequirements []VersionRequirement `json:"versionRequirements,omitempty"`
274276
}

src/cmd/package.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func newPackageCommand() *cobra.Command {
6060
cmd.AddCommand(newPackageListCommand())
6161
cmd.AddCommand(newPackagePublishCommand(v))
6262
cmd.AddCommand(newPackagePullCommand(v))
63+
cmd.AddCommand(newPackageSignCommand(v))
6364

6465
return cmd
6566
}
@@ -1582,6 +1583,153 @@ func (o *packagePullOptions) run(cmd *cobra.Command, args []string) error {
15821583
return nil
15831584
}
15841585

1586+
type packageSignOptions struct {
1587+
signingKeyPath string
1588+
signingKeyPassword string
1589+
publicKeyPath string
1590+
overwrite bool
1591+
output string
1592+
ociConcurrency int
1593+
retries int
1594+
}
1595+
1596+
func newPackageSignCommand(v *viper.Viper) *cobra.Command {
1597+
o := &packageSignOptions{}
1598+
1599+
cmd := &cobra.Command{
1600+
Use: "sign PACKAGE_SOURCE",
1601+
Aliases: []string{"s"},
1602+
Args: cobra.ExactArgs(1),
1603+
Short: lang.CmdPackageSignShort,
1604+
Long: lang.CmdPackageSignLong,
1605+
Example: lang.CmdPackageSignExample,
1606+
RunE: o.run,
1607+
}
1608+
1609+
cmd.Flags().StringVar(&o.signingKeyPath, "signing-key", v.GetString(VPkgSignSigningKey), lang.CmdPackageSignFlagSigningKey)
1610+
cmd.Flags().StringVar(&o.signingKeyPassword, "signing-key-pass", v.GetString(VPkgSignSigningKeyPassword), lang.CmdPackageSignFlagSigningKeyPass)
1611+
cmd.Flags().StringVarP(&o.output, "output", "o", v.GetString(VPkgSignOutput), lang.CmdPackageSignFlagOutput)
1612+
cmd.Flags().BoolVar(&o.overwrite, "overwrite", v.GetBool(VPkgSignOverwrite), lang.CmdPackageSignFlagOverwrite)
1613+
cmd.Flags().StringVarP(&o.publicKeyPath, "key", "k", v.GetString(VPkgPublicKey), lang.CmdPackageSignFlagKey)
1614+
cmd.Flags().IntVar(&o.ociConcurrency, "oci-concurrency", v.GetInt(VPkgOCIConcurrency), lang.CmdPackageFlagConcurrency)
1615+
cmd.Flags().IntVar(&o.retries, "retries", v.GetInt(VPkgRetries), lang.CmdPackageFlagRetries)
1616+
1617+
return cmd
1618+
}
1619+
1620+
func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error {
1621+
ctx := cmd.Context()
1622+
l := logger.From(ctx)
1623+
packageSource := args[0]
1624+
1625+
if o.signingKeyPath == "" {
1626+
return errors.New("--signing-key is required")
1627+
}
1628+
1629+
// Determine output destination
1630+
outputDest := o.output
1631+
if outputDest == "" {
1632+
if helpers.IsOCIURL(packageSource) {
1633+
// For OCI sources, default to publishing back to the same OCI location
1634+
// Extract the repository portion (without package name and tag) from source
1635+
trimmed := strings.TrimPrefix(packageSource, helpers.OCIURLPrefix)
1636+
srcRef, err := registry.ParseReference(trimmed)
1637+
if err != nil {
1638+
return fmt.Errorf("failed to parse source OCI reference: %w", err)
1639+
}
1640+
1641+
// Extract repository path without the package name
1642+
// e.g., "registry.com/namespace/package:tag" -> "registry.com/namespace"
1643+
repoParts := strings.Split(srcRef.Repository, "/")
1644+
if len(repoParts) > 1 {
1645+
// Remove the last part (package name)
1646+
repoPath := strings.Join(repoParts[:len(repoParts)-1], "/")
1647+
outputDest = helpers.OCIURLPrefix + srcRef.Registry + "/" + repoPath
1648+
} else {
1649+
// Package is directly under registry (no namespace)
1650+
outputDest = helpers.OCIURLPrefix + srcRef.Registry
1651+
}
1652+
} else {
1653+
// For file sources, use the same directory as the source
1654+
outputDest = filepath.Dir(packageSource)
1655+
}
1656+
}
1657+
1658+
// If output is OCI (either default or user-specified), delegate to publish workflow
1659+
if helpers.IsOCIURL(outputDest) {
1660+
l.Info("signing and publishing package to OCI registry", "source", packageSource, "destination", outputDest)
1661+
1662+
// Create publish options from sign options
1663+
publishOpts := &packagePublishOptions{
1664+
signingKeyPath: o.signingKeyPath,
1665+
signingKeyPassword: o.signingKeyPassword,
1666+
skipSignatureValidation: o.overwrite, // Use overwrite flag for skip validation
1667+
ociConcurrency: o.ociConcurrency,
1668+
retries: o.retries,
1669+
publicKeyPath: o.publicKeyPath,
1670+
}
1671+
1672+
// Call publish with source and destination repository
1673+
return publishOpts.run(cmd, []string{packageSource, outputDest})
1674+
}
1675+
1676+
// For local file output, use existing sign logic
1677+
cachePath, err := getCachePath(ctx)
1678+
if err != nil {
1679+
return err
1680+
}
1681+
1682+
// Load the package
1683+
loadOpts := packager.LoadOptions{
1684+
PublicKeyPath: o.publicKeyPath,
1685+
SkipSignatureValidation: o.overwrite,
1686+
Filter: filters.Empty(),
1687+
Architecture: config.GetArch(),
1688+
OCIConcurrency: o.ociConcurrency,
1689+
RemoteOptions: defaultRemoteOptions(),
1690+
CachePath: cachePath,
1691+
}
1692+
1693+
l.Info("loading package", "source", packageSource)
1694+
pkgLayout, err := packager.LoadPackage(ctx, packageSource, loadOpts)
1695+
if err != nil {
1696+
return fmt.Errorf("unable to load package: %w", err)
1697+
}
1698+
defer func() {
1699+
if cleanupErr := pkgLayout.Cleanup(); cleanupErr != nil {
1700+
l.Warn("failed to cleanup package layout", "error", cleanupErr)
1701+
}
1702+
}()
1703+
1704+
signed := pkgLayout.IsSigned()
1705+
1706+
if signed && !o.overwrite {
1707+
return errors.New("package is already signed, use --overwrite to re-sign")
1708+
}
1709+
1710+
// Sign the package
1711+
l.Info("signing package with provided key")
1712+
1713+
signOpts := utils.DefaultSignBlobOptions()
1714+
signOpts.KeyRef = o.signingKeyPath
1715+
signOpts.Password = o.signingKeyPassword
1716+
1717+
err = pkgLayout.SignPackage(ctx, signOpts)
1718+
if err != nil {
1719+
return fmt.Errorf("failed to sign package: %w", err)
1720+
}
1721+
1722+
// Archive to local directory
1723+
l.Info("archiving signed package to local directory", "directory", outputDest)
1724+
signedPath, err := pkgLayout.Archive(ctx, outputDest, 0)
1725+
if err != nil {
1726+
return fmt.Errorf("failed to archive signed package: %w", err)
1727+
}
1728+
1729+
l.Info("package signed successfully", "path", signedPath)
1730+
return nil
1731+
}
1732+
15851733
func choosePackage(ctx context.Context, args []string) (string, error) {
15861734
if len(args) > 0 {
15871735
return args[0], nil

src/cmd/viper.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ const (
109109
VPkgPublishRetries = "package.publish.retries"
110110
VPkgPublishWithBuildMachineInfo = "package.publish.with_build_machine_info"
111111

112+
// Package sign config keys
113+
114+
VPkgSignSigningKey = "package.sign.signing_key"
115+
VPkgSignSigningKeyPassword = "package.sign.signing_key_password"
116+
VPkgSignOutput = "package.sign.output"
117+
VPkgSignOverwrite = "package.sign.overwrite"
118+
112119
// Package pull config keys
113120

114121
VPkgPullOutputDir = "package.pull.output_directory"

src/config/lang/english.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,30 @@ $ zarf package publish ./path/to/dir oci://my-registry.com/my-namespace
311311
CmdPackagePublishFlagConfirm = "Confirms package publish without prompting. Skips prompt for the signing key password"
312312
CmdPackagePublishFlagFlavor = "The flavor of components to include in the resulting package. The flavor will be appended to the package tag"
313313

314+
CmdPackageSignShort = "Signs an existing Zarf package"
315+
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."
316+
CmdPackageSignExample = `
317+
# Sign an unsigned package
318+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./private-key.pem
319+
320+
# Re-sign with a new key (overwrite existing signature)
321+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key ./new-key.pem --overwrite
322+
323+
# Sign a package from an OCI registry and output to local directory
324+
$ zarf package sign oci://ghcr.io/my-org/my-package:1.0.0 --signing-key ./private-key.pem --output ./signed/
325+
326+
# Sign a package and publish directly to OCI registry
327+
$ 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
328+
329+
# Sign with a cloud KMS key
330+
$ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms://alias/my-signing-key
331+
`
332+
CmdPackageSignFlagSigningKey = "Private key for signing packages. Accepts either a local file path or a Cosign-supported key provider (awskms://, gcpkms://, azurekms://, hashivault://)"
333+
CmdPackageSignFlagSigningKeyPass = "Password for encrypted private key"
334+
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"
335+
CmdPackageSignFlagOverwrite = "Overwrite an existing signature if the package is already signed"
336+
CmdPackageSignFlagKey = "Public key to verify the existing signature before re-signing (optional)"
337+
314338
CmdPackagePullShort = "Pulls a Zarf package from a remote registry and save to the local file system"
315339
CmdPackagePullExample = `
316340
# Pull a package matching the current architecture

src/pkg/packager/layout/assemble.go

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import (
2222

2323
"github.com/defenseunicorns/pkg/helpers/v2"
2424
goyaml "github.com/goccy/go-yaml"
25-
"github.com/sigstore/cosign/v3/cmd/cosign/cli/options"
26-
"github.com/sigstore/cosign/v3/cmd/cosign/cli/sign"
2725
"github.com/zarf-dev/zarf/src/api/v1alpha1"
2826
"github.com/zarf-dev/zarf/src/config"
2927
"github.com/zarf-dev/zarf/src/config/lang"
@@ -187,12 +185,17 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath
187185
return nil, err
188186
}
189187

190-
err = signPackage(buildPath, opts.SigningKeyPath, opts.SigningKeyPassword)
188+
pkgLayout, err := LoadFromDir(ctx, buildPath, PackageLayoutOptions{SkipSignatureValidation: true})
191189
if err != nil {
192190
return nil, err
193191
}
194192

195-
pkgLayout, err := LoadFromDir(ctx, buildPath, PackageLayoutOptions{SkipSignatureValidation: true})
193+
// Sign the package with the provided options
194+
signOpts := utils.DefaultSignBlobOptions()
195+
signOpts.KeyRef = opts.SigningKeyPath
196+
signOpts.Password = opts.SigningKeyPassword
197+
198+
err = pkgLayout.SignPackage(ctx, signOpts)
196199
if err != nil {
197200
return nil, err
198201
}
@@ -251,11 +254,6 @@ func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath
251254
return nil, err
252255
}
253256

254-
err = signPackage(buildPath, opts.SigningKeyPath, opts.SigningKeyPassword)
255-
if err != nil {
256-
return nil, err
257-
}
258-
259257
layoutOpts := PackageLayoutOptions{
260258
SkipSignatureValidation: true,
261259
IsPartial: false,
@@ -265,6 +263,16 @@ func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath
265263
return nil, fmt.Errorf("unable to load skeleton: %w", err)
266264
}
267265

266+
// Sign the package with the provided options
267+
signOpts := utils.DefaultSignBlobOptions()
268+
signOpts.KeyRef = opts.SigningKeyPath
269+
signOpts.Password = opts.SigningKeyPassword
270+
271+
err = pkgLayout.SignPackage(ctx, signOpts)
272+
if err != nil {
273+
return nil, err
274+
}
275+
268276
return pkgLayout, nil
269277
}
270278

@@ -740,6 +748,10 @@ func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOver
740748

741749
pkg.Build.RegistryOverrides = overrides
742750

751+
// set signed to false by default - this is updated if signing occurs.
752+
signed := false
753+
pkg.Build.Signed = &signed
754+
743755
return pkg
744756
}
745757

@@ -776,35 +788,6 @@ func getChecksum(dirPath string) (string, string, error) {
776788
return checksumContent, hex.EncodeToString(sha[:]), nil
777789
}
778790

779-
func signPackage(dirPath, signingKeyPath, signingKeyPassword string) error {
780-
if signingKeyPath == "" {
781-
return nil
782-
}
783-
passFunc := func(_ bool) ([]byte, error) {
784-
return []byte(signingKeyPassword), nil
785-
}
786-
keyOpts := options.KeyOpts{
787-
KeyRef: signingKeyPath,
788-
PassFunc: passFunc,
789-
}
790-
rootOpts := &options.RootOptions{
791-
Verbose: false,
792-
Timeout: options.DefaultTimeout,
793-
}
794-
_, err := sign.SignBlobCmd(
795-
rootOpts,
796-
keyOpts,
797-
filepath.Join(dirPath, ZarfYAML),
798-
true,
799-
filepath.Join(dirPath, Signature),
800-
"",
801-
false)
802-
if err != nil {
803-
return err
804-
}
805-
return nil
806-
}
807-
808791
func createReproducibleTarballFromDir(dirPath, dirPrefix, tarballPath string, overrideMode bool) (err error) {
809792
tb, err := os.Create(tarballPath)
810793
if err != nil {

0 commit comments

Comments
 (0)