diff --git a/RELEASES.md b/RELEASES.md index a8ac0e8985..4c84c7c20f 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -60,6 +60,7 @@ Once the prerelease artifacts are published, a Homebrew Tap PR is created. It is ### Standard Releases (via Release Please) +* [ ] Refresh the embedded Sigstore TrustedRoot used for keyless verification (see [Embedded TrustedRoot](#embedded-trustedroot)) * [ ] Review and merge the open Release Please PR * [ ] The tag is automatically created and pushed, triggering the release workflow * [ ] Review the GitHub release: @@ -67,6 +68,19 @@ Once the prerelease artifacts are published, a Homebrew Tap PR is created. It is * [ ] Ensure goreleaser workflows execute successfully and review the release assets * [ ] Review, approve, and merge the [homebrew-tap](https://github.com/defenseunicorns/homebrew-tap) PR for the zarf release +### Embedded TrustedRoot + +Zarf binaries embed a Sigstore TrustedRoot JSON used by `zarf package verify` for keyless verification when `--trusted-root` is not supplied. The TUF-fetched copy is committed at `src/pkg/utils/embedded_trusted_root.json` and must be refreshed before each release so binaries ship with current Sigstore trust material. + +```bash +make build +hack/refresh-trusted-root.sh +git add src/pkg/utils/embedded_trusted_root.json +git commit -m "chore: refresh embedded trusted root" +``` + +The script wraps `zarf tools trusted-root create --with-default-services`, which reaches `tuf-repo-cdn.sigstore.dev` and writes the verified TrustedRoot to the embed path. Users who run their own Sigstore infrastructure can bypass the embedded copy at runtime via `zarf package verify --trusted-root /path/to/custom.json`. + ### Manual Releases (if needed) For cases where you need to manually create a release (e.g., release candidates): diff --git a/hack/refresh-trusted-root.sh b/hack/refresh-trusted-root.sh new file mode 100755 index 0000000000..cf56e7c9dc --- /dev/null +++ b/hack/refresh-trusted-root.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Refresh the embedded Sigstore TrustedRoot used for keyless verification. +# Run before each release. Commit the result. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EMBED_PATH="${REPO_ROOT}/src/pkg/signing/embedded_trusted_root.json" +ZARF_BIN="${REPO_ROOT}/build/zarf" + +if [ ! -x "${ZARF_BIN}" ]; then + echo "build/zarf not found; run 'make build' first" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required to format the embedded trusted root for reviewable diffs" >&2 + exit 1 +fi + +"${ZARF_BIN}" tools trusted-root create --with-default-services --out "${EMBED_PATH}" +jq --indent 2 . "${EMBED_PATH}" > "${EMBED_PATH}.tmp" && mv "${EMBED_PATH}.tmp" "${EMBED_PATH}" +echo "Refreshed ${EMBED_PATH}" diff --git a/site/src/content/docs/commands/zarf_package_sign.md b/site/src/content/docs/commands/zarf_package_sign.md index a6ebcdbc74..cd3f86d3c2 100644 --- a/site/src/content/docs/commands/zarf_package_sign.md +++ b/site/src/content/docs/commands/zarf_package_sign.md @@ -42,14 +42,23 @@ $ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms:/ ### Options ``` + --confirm Skip the interactive confirmation prompt before uploading to the Rekor transparency log (equivalent to cosign --yes). + --fulcio-auth-flow string Fulcio OAuth flow: normal (browser), device (device code), token, client_credentials (default "normal") + --fulcio-url string Fulcio certificate authority URL. Override for private Sigstore deployments. (default "https://fulcio.sigstore.dev") -h, --help help for sign + --identity-token string Pre-acquired OIDC identity token (or path to a file containing one) for non-interactive keyless signing -k, --key string Public key to verify the existing signature before re-signing (optional) + --keyless Sign without a private key using Sigstore's keyless flow (Fulcio/OIDC) --oci-concurrency int Number of concurrent layer operations when pulling or pushing images or packages to/from OCI registries. (default 6) + --oidc-client-id string OIDC client ID used when requesting an identity token. Override for private Sigstore deployments. (default "sigstore") + --oidc-issuer string OIDC issuer URL used to obtain an identity token for keyless signing. Override for private Sigstore deployments. (default "https://oauth2.sigstore.dev/auth") -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 + --rekor-url string Rekor transparency log URL. Override for private Sigstore deployments. (default "https://rekor.sigstore.dev") --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 + --tlog-upload Upload the signature to the Rekor transparency log. Auto-enabled when --keyless is set (required for keyless signatures to remain verifiable past the ~10 minute Fulcio certificate validity window). --verify Verify the Zarf package signature ``` diff --git a/site/src/content/docs/commands/zarf_package_verify.md b/site/src/content/docs/commands/zarf_package_verify.md index 86819bfb77..b2fb5e9f22 100644 --- a/site/src/content/docs/commands/zarf_package_verify.md +++ b/site/src/content/docs/commands/zarf_package_verify.md @@ -33,9 +33,15 @@ $ zarf package verify zarf-package-demo-amd64-1.0.0.tar.zst ### Options ``` - -h, --help help for verify - -k, --key string Public key for signature verification - --oci-concurrency int Number of concurrent layer operations when pulling or pushing images or packages to/from OCI registries. (default 6) + --certificate-identity string Required identity claim in the signing certificate (keyless verify). Example: signer@example.com or https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main + --certificate-identity-regexp string Regex variant of --certificate-identity + --certificate-oidc-issuer string Required OIDC issuer claim in the signing certificate (keyless verify). Example: https://github.com/login/oauth or https://token.actions.githubusercontent.com + --certificate-oidc-issuer-regexp string Regex variant of --certificate-oidc-issuer + -h, --help help for verify + --insecure-ignore-tlog Skip Rekor transparency log inclusion verification. Default true for air-gap. Auto-disabled when keyless identity flags are set (keyless signatures require Rekor inclusion proof to remain verifiable past certificate expiry). (default true) + -k, --key string Public key for signature verification + --oci-concurrency int Number of concurrent layer operations when pulling or pushing images or packages to/from OCI registries. (default 6) + --trusted-root string Path to a Sigstore TrustedRoot JSON. Falls back to the binary-embedded copy when omitted. ``` ### Options inherited from parent commands diff --git a/src/cmd/package.go b/src/cmd/package.go index ded80eebe5..1301b44c17 100644 --- a/src/cmd/package.go +++ b/src/cmd/package.go @@ -37,6 +37,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/packager" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/layout" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/state" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/pkg/zoci" @@ -1746,13 +1747,17 @@ func (o *packagePublishOptions) run(cmd *cobra.Command, args []string) error { err = errors.Join(err, pkgLayout.Cleanup()) }() + publishSignOpts := signing.DefaultSignBlobOptions() + publishSignOpts.Key = o.signingKeyPath + publishSignOpts.Password = o.signingKeyPassword + publishSignOpts.Overwrite = true + publishPackageOpts := packager.PublishPackageOptions{ - OCIConcurrency: o.ociConcurrency, - SigningKeyPath: o.signingKeyPath, - SigningKeyPassword: o.signingKeyPassword, - Retries: o.retries, - RemoteOptions: defaultRemoteOptions(), - Tag: o.tag, + OCIConcurrency: o.ociConcurrency, + SignBlobOptions: publishSignOpts, + Retries: o.retries, + RemoteOptions: defaultRemoteOptions(), + Tag: o.tag, } _, err = packager.PublishPackage(ctx, pkgLayout, dstRef, publishPackageOpts) @@ -1848,6 +1853,17 @@ type packageSignOptions struct { ociConcurrency int retries int verify bool + // Keyless signing flags. Each is hand-rolled and individually opted-in; + // new cosign flags will not appear here automatically on dependency bumps. + keyless bool + identityToken string + fulcioURL string + fulcioAuthFlow string + oidcIssuer string + oidcClientID string + rekorURL string + tlogUpload bool + confirm bool } func newPackageSignCommand(v *viper.Viper) *cobra.Command { @@ -1872,6 +1888,18 @@ func newPackageSignCommand(v *viper.Viper) *cobra.Command { cmd.Flags().IntVar(&o.retries, "retries", v.GetInt(VPkgRetries), lang.CmdPackageFlagRetries) cmd.Flags().BoolVar(&o.verify, "verify", v.GetBool(VPkgVerify), lang.CmdPackageFlagVerify) + cmd.Flags().BoolVar(&o.keyless, "keyless", false, lang.CmdPackageSignFlagKeyless) + cmd.Flags().StringVar(&o.identityToken, "identity-token", "", lang.CmdPackageSignFlagIdentityToken) + cmd.Flags().StringVar(&o.fulcioURL, "fulcio-url", v.GetString(VPkgSignFulcioURL), lang.CmdPackageSignFlagFulcioURL) + cmd.Flags().StringVar(&o.fulcioAuthFlow, "fulcio-auth-flow", v.GetString(VPkgSignFulcioAuthFlow), lang.CmdPackageSignFlagFulcioAuthFlow) + cmd.Flags().StringVar(&o.oidcIssuer, "oidc-issuer", v.GetString(VPkgSignOIDCIssuer), lang.CmdPackageSignFlagOIDCIssuer) + cmd.Flags().StringVar(&o.oidcClientID, "oidc-client-id", v.GetString(VPkgSignOIDCClientID), lang.CmdPackageSignFlagOIDCClientID) + cmd.Flags().StringVar(&o.rekorURL, "rekor-url", v.GetString(VPkgSignRekorURL), lang.CmdPackageSignFlagRekorURL) + cmd.Flags().BoolVar(&o.tlogUpload, "tlog-upload", v.GetBool(VPkgSignTlogUpload), lang.CmdPackageSignFlagTlogUpload) + cmd.Flags().BoolVar(&o.confirm, "confirm", false, lang.CmdPackageSignFlagConfirm) + + cmd.MarkFlagsMutuallyExclusive("keyless", "signing-key") + return cmd } @@ -1880,8 +1908,8 @@ func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error { l := logger.From(ctx) packageSource := args[0] - if o.signingKeyPath == "" { - return errors.New("--signing-key is required") + if !o.keyless && o.signingKeyPath == "" { + return errors.New("--signing-key is required (or pass --keyless for Sigstore keyless flow)") } // Determine output destination @@ -1913,31 +1941,34 @@ func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error { } } - // If output is OCI (either default or user-specified), delegate to publish workflow - if helpers.IsOCIURL(outputDest) { - l.Info("signing and publishing package to OCI registry", "source", packageSource, "destination", outputDest) - - // Create publish options from sign options - publishOpts := &packagePublishOptions{ - signingKeyPath: o.signingKeyPath, - signingKeyPassword: o.signingKeyPassword, - ociConcurrency: o.ociConcurrency, - retries: o.retries, - publicKeyPath: o.publicKeyPath, - verify: o.verify, - } - - // Call publish with source and destination repository - return publishOpts.run(cmd, []string{packageSource, outputDest}) - } - - // For local file output, use existing sign logic cachePath, err := getCachePath(ctx) if err != nil { return err } - // Load the package - do not verify + // Pull from OCI to a local temp dir before loading + if helpers.IsOCIURL(packageSource) { + tmpdir, err := utils.MakeTempDir(config.CommonOptions.TempDirectory) + if err != nil { + return err + } + defer func() { + if removeErr := os.RemoveAll(tmpdir); removeErr != nil { + l.Warn("failed to remove temp dir", "error", removeErr) + } + }() + packageSource, err = packager.Pull(ctx, packageSource, tmpdir, packager.PullOptions{ + VerificationStrategy: layout.VerifyNever, + Architecture: config.GetArch(), + OCIConcurrency: o.ociConcurrency, + RemoteOptions: defaultRemoteOptions(), + CachePath: cachePath, + }) + if err != nil { + return fmt.Errorf("failed to pull package: %w", err) + } + } + loadOpts := packager.LoadOptions{ Filter: filters.Empty(), Architecture: config.GetArch(), @@ -1974,20 +2005,57 @@ func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error { } } - // Sign the package - l.Info("signing package with provided key") + if o.keyless { + l.Info("signing package via Sigstore keyless flow") + } else { + l.Info("signing package with provided key") + } - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = o.signingKeyPath signOpts.Password = o.signingKeyPassword signOpts.Overwrite = o.overwrite + signOpts.Keyless = o.keyless + signOpts.Fulcio.IdentityToken = o.identityToken + signOpts.Fulcio.URL = o.fulcioURL + signOpts.Fulcio.AuthFlow = o.fulcioAuthFlow + signOpts.OIDC.Issuer = o.oidcIssuer + signOpts.OIDC.ClientID = o.oidcClientID + signOpts.Rekor.URL = o.rekorURL + signOpts.TlogUpload = o.tlogUpload + signOpts.SkipConfirmation = o.confirm + + // Keyless certs are short-lived (~10 min). Without Rekor or a TSA timestamp + // the signature is unverifiable past expiry. Default --tlog-upload=true for + // keyless unless the user explicitly opted out. + if o.keyless && !cmd.Flags().Changed("tlog-upload") { + signOpts.TlogUpload = true + } + + if helpers.IsOCIURL(outputDest) { + parts := strings.Split(strings.TrimPrefix(outputDest, helpers.OCIURLPrefix), "/") + dstRef := registry.Reference{ + Registry: parts[0], + Repository: strings.Join(parts[1:], "/"), + } + if err := dstRef.ValidateRegistry(); err != nil { + return fmt.Errorf("invalid destination registry: %w", err) + } + l.Info("signing and publishing package to OCI registry", "destination", outputDest) + _, err = packager.PublishPackage(ctx, pkgLayout, dstRef, packager.PublishPackageOptions{ + OCIConcurrency: o.ociConcurrency, + SignBlobOptions: signOpts, + Retries: o.retries, + RemoteOptions: defaultRemoteOptions(), + }) + return err + } err = pkgLayout.SignPackage(ctx, signOpts) if err != nil { return fmt.Errorf("failed to sign package: %w", err) } - // Archive to local directory l.Info("archiving signed package to local directory", "directory", outputDest) signedPath, err := pkgLayout.Archive(ctx, outputDest, 0) if err != nil { @@ -2001,6 +2069,13 @@ func (o *packageSignOptions) run(cmd *cobra.Command, args []string) error { type packageVerifyOptions struct { publicKeyPath string ociConcurrency int + // Keyless verify flags. Each is hand-rolled and individually opted-in. + certificateIdentity string + certificateIdentityRegexp string + certificateOIDCIssuer string + certificateOIDCIssuerRegexp string + trustedRoot string + insecureIgnoreTlog bool } func newPackageVerifyCommand(v *viper.Viper) *cobra.Command { @@ -2019,6 +2094,13 @@ func newPackageVerifyCommand(v *viper.Viper) *cobra.Command { cmd.Flags().StringVarP(&o.publicKeyPath, "key", "k", v.GetString(VPkgPublicKey), lang.CmdPackageVerifyFlagKey) cmd.Flags().IntVar(&o.ociConcurrency, "oci-concurrency", v.GetInt(VPkgOCIConcurrency), lang.CmdPackageFlagConcurrency) + cmd.Flags().StringVar(&o.certificateIdentity, "certificate-identity", v.GetString(VPkgVerifyCertIdentity), lang.CmdPackageVerifyFlagCertificateIdentity) + cmd.Flags().StringVar(&o.certificateIdentityRegexp, "certificate-identity-regexp", v.GetString(VPkgVerifyCertIdentityRegexp), lang.CmdPackageVerifyFlagCertificateIdentityRegexp) + cmd.Flags().StringVar(&o.certificateOIDCIssuer, "certificate-oidc-issuer", v.GetString(VPkgVerifyCertOIDCIssuer), lang.CmdPackageVerifyFlagCertificateOIDCIssuer) + cmd.Flags().StringVar(&o.certificateOIDCIssuerRegexp, "certificate-oidc-issuer-regexp", v.GetString(VPkgVerifyCertOIDCIssuerRegexp), lang.CmdPackageVerifyFlagCertificateOIDCIssuerRegexp) + cmd.Flags().StringVar(&o.trustedRoot, "trusted-root", v.GetString(VPkgVerifyTrustedRoot), lang.CmdPackageVerifyFlagTrustedRoot) + cmd.Flags().BoolVar(&o.insecureIgnoreTlog, "insecure-ignore-tlog", v.GetBool(VPkgVerifyInsecureIgnoreTlog), lang.CmdPackageVerifyFlagInsecureIgnoreTlog) + return cmd } @@ -2037,8 +2119,24 @@ func (o *packageVerifyOptions) run(cmd *cobra.Command, args []string) error { // Load the package with verification enabled // The verify command always uses strict verification (VerifyAlways) // This will error if: signed package without key, or unsigned package with key + verifyOpts := signing.DefaultVerifyBlobOptions() + verifyOpts.Key = o.publicKeyPath + verifyOpts.CertVerify.CertIdentity = o.certificateIdentity + verifyOpts.CertVerify.CertIdentityRegexp = o.certificateIdentityRegexp + verifyOpts.CertVerify.CertOidcIssuer = o.certificateOIDCIssuer + verifyOpts.CertVerify.CertOidcIssuerRegexp = o.certificateOIDCIssuerRegexp + verifyOpts.CommonVerifyOptions.TrustedRootPath = o.trustedRoot + verifyOpts.CommonVerifyOptions.IgnoreTlog = o.insecureIgnoreTlog + + // Optimally by default use the inclusion proof to establish when a signature was made. + // this is offline-compliant and airgap compatible given keyless signed bundle outputs. + hasKeylessIdentity := o.certificateIdentity != "" || o.certificateIdentityRegexp != "" + if hasKeylessIdentity && !cmd.Flags().Changed("insecure-ignore-tlog") { + verifyOpts.CommonVerifyOptions.IgnoreTlog = false + } + loadOpts := packager.LoadOptions{ - VerifyBlobOptions: verifyBlobOptionsFromKeyPath(o.publicKeyPath), + VerifyBlobOptions: &verifyOpts, VerificationStrategy: layout.VerifyAlways, // Always enforce strict verification Filter: filters.Empty(), Architecture: config.GetArch(), @@ -2145,8 +2243,8 @@ func getVerificationStrategy(verify bool) layout.VerificationStrategy { return layout.VerifyIfPossible } -func verifyBlobOptionsFromKeyPath(keyPath string) *utils.VerifyBlobOptions { - opts := utils.DefaultVerifyBlobOptions() +func verifyBlobOptionsFromKeyPath(keyPath string) *signing.VerifyBlobOptions { + opts := signing.DefaultVerifyBlobOptions() opts.Key = keyPath return &opts } diff --git a/src/cmd/tools_trustedroot.go b/src/cmd/tools_trustedroot.go index fc687d0bfe..f488c5a083 100644 --- a/src/cmd/tools_trustedroot.go +++ b/src/cmd/tools_trustedroot.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/zarf-dev/zarf/src/config/lang" - "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/signing" ) func newTrustedRootCommand() *cobra.Command { @@ -79,7 +79,7 @@ func newTrustedRootCreateCommand() *cobra.Command { return errors.New("provide --with-default-services to retrieve the public Sigstore trusted root, or specify --fulcio/--rekor/--ctfe/--tsa to compose a custom trusted root") } - ctx, cancel := context.WithTimeout(cmd.Context(), utils.CosignDefaultTimeout) + ctx, cancel := context.WithTimeout(cmd.Context(), signing.CosignDefaultTimeout) defer cancel() return optionsToCreateCmd(o).Exec(ctx) }, diff --git a/src/cmd/viper.go b/src/cmd/viper.go index 6051567f0e..b33e096791 100644 --- a/src/cmd/viper.go +++ b/src/cmd/viper.go @@ -121,6 +121,24 @@ const ( VPkgSignOutput = "package.sign.output" VPkgSignOverwrite = "package.sign.overwrite" + // Package sign keyless config keys + + VPkgSignFulcioURL = "package.sign.fulcio_url" + VPkgSignFulcioAuthFlow = "package.sign.fulcio_auth_flow" + VPkgSignOIDCIssuer = "package.sign.oidc_issuer" + VPkgSignOIDCClientID = "package.sign.oidc_client_id" + VPkgSignRekorURL = "package.sign.rekor_url" + VPkgSignTlogUpload = "package.sign.tlog_upload" + + // Package verify keyless config keys + + VPkgVerifyCertIdentity = "package.verify.certificate_identity" + VPkgVerifyCertIdentityRegexp = "package.verify.certificate_identity_regexp" + VPkgVerifyCertOIDCIssuer = "package.verify.certificate_oidc_issuer" + VPkgVerifyCertOIDCIssuerRegexp = "package.verify.certificate_oidc_issuer_regexp" + VPkgVerifyTrustedRoot = "package.verify.trusted_root" + VPkgVerifyInsecureIgnoreTlog = "package.verify.insecure_ignore_tlog" + // Package pull config keys VPkgPullOutputDir = "package.pull.output_directory" @@ -252,6 +270,16 @@ func setDefaults() { // Package publish opts that are non-zero values v.SetDefault(VPkgPublishRetries, 1) + // Package sign keyless opts that are non-zero values + v.SetDefault(VPkgSignFulcioURL, "https://fulcio.sigstore.dev") + v.SetDefault(VPkgSignFulcioAuthFlow, "normal") + v.SetDefault(VPkgSignOIDCIssuer, "https://oauth2.sigstore.dev/auth") + v.SetDefault(VPkgSignOIDCClientID, "sigstore") + v.SetDefault(VPkgSignRekorURL, "https://rekor.sigstore.dev") + + // Package verify opts that are non-zero values + v.SetDefault(VPkgVerifyInsecureIgnoreTlog, true) + // Dev deploy defaults v.SetDefault(VDevDeployConnected, true) } diff --git a/src/config/lang/english.go b/src/config/lang/english.go index c0553539ce..3dd1543f63 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -358,6 +358,15 @@ $ zarf package sign zarf-package-demo-amd64-1.0.0.tar.zst --signing-key awskms:/ 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)" + CmdPackageSignFlagKeyless = "Sign without a private key using Sigstore's keyless flow (Fulcio/OIDC)" + CmdPackageSignFlagIdentityToken = "Pre-acquired OIDC identity token (or path to a file containing one) for non-interactive keyless signing" + CmdPackageSignFlagFulcioURL = "Fulcio certificate authority URL. Override for private Sigstore deployments." + CmdPackageSignFlagFulcioAuthFlow = "Fulcio OAuth flow: normal (browser), device (device code), token, client_credentials" + CmdPackageSignFlagOIDCIssuer = "OIDC issuer URL used to obtain an identity token for keyless signing. Override for private Sigstore deployments." + CmdPackageSignFlagOIDCClientID = "OIDC client ID used when requesting an identity token. Override for private Sigstore deployments." + CmdPackageSignFlagRekorURL = "Rekor transparency log URL. Override for private Sigstore deployments." + CmdPackageSignFlagTlogUpload = "Upload the signature to the Rekor transparency log. Auto-enabled when --keyless is set (required for keyless signatures to remain verifiable past the ~10 minute Fulcio certificate validity window)." + CmdPackageSignFlagConfirm = "Skip the interactive confirmation prompt before uploading to the Rekor transparency log (equivalent to cosign --yes)." CmdPackageVerifyShort = "Verify the signature and integrity of a Zarf package" CmdPackageVerifyLong = "Verify the cryptographic signature (if signed) and checksum integrity of a Zarf package. Returns exit code 0 if valid, non-zero if verification fails." @@ -368,7 +377,13 @@ $ zarf package verify zarf-package-demo-amd64-1.0.0.tar.zst --key ./public-key.p # Verify an unsigned package (checksums only) $ zarf package verify zarf-package-demo-amd64-1.0.0.tar.zst ` - CmdPackageVerifyFlagKey = "Public key for signature verification" + CmdPackageVerifyFlagKey = "Public key for signature verification" + CmdPackageVerifyFlagCertificateIdentity = "Required identity claim in the signing certificate (keyless verify). Example: signer@example.com or https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main" + CmdPackageVerifyFlagCertificateIdentityRegexp = "Regex variant of --certificate-identity" + CmdPackageVerifyFlagCertificateOIDCIssuer = "Required OIDC issuer claim in the signing certificate (keyless verify). Example: https://github.com/login/oauth or https://token.actions.githubusercontent.com" + CmdPackageVerifyFlagCertificateOIDCIssuerRegexp = "Regex variant of --certificate-oidc-issuer" + CmdPackageVerifyFlagTrustedRoot = "Path to a Sigstore TrustedRoot JSON. Falls back to the binary-embedded copy when omitted." + CmdPackageVerifyFlagInsecureIgnoreTlog = "Skip Rekor transparency log inclusion verification. Default true for air-gap. Auto-disabled when keyless identity flags are set (keyless signatures require Rekor inclusion proof to remain verifiable past certificate expiry)." CmdPackagePullShort = "Pulls a Zarf package from a remote registry and save to the local file system" CmdPackagePullExample = ` diff --git a/src/pkg/feature/feature.go b/src/pkg/feature/feature.go index 7fe52ccd63..03a26c0d0c 100644 --- a/src/pkg/feature/feature.go +++ b/src/pkg/feature/feature.go @@ -247,7 +247,7 @@ func init() { { Name: BundleSignature, Description: "Enables Sigstore bundle format signatures. When disabled, only legacy signature format is produced.", - Enabled: false, + Enabled: true, Since: "v0.72.0", Stage: Alpha, }, diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index 0b1705e014..768939a6bc 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -33,6 +33,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/packager/actions" "github.com/zarf-dev/zarf/src/pkg/packager/filters" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/pkg/value" @@ -220,7 +221,7 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } // Sign the package with the provided options - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = opts.SigningKeyPath signOpts.Password = opts.SigningKeyPassword @@ -302,7 +303,7 @@ func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } // Sign the package with the provided options - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = opts.SigningKeyPath signOpts.Password = opts.SigningKeyPassword diff --git a/src/pkg/packager/layout/package.go b/src/pkg/packager/layout/package.go index 81e76ac914..71eea643be 100644 --- a/src/pkg/packager/layout/package.go +++ b/src/pkg/packager/layout/package.go @@ -25,6 +25,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/packager/filters" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/utils" ) @@ -42,7 +43,7 @@ type PackageLayoutOptions struct { VerificationStrategy VerificationStrategy IsPartial bool Filter filters.ComponentFilterStrategy - VerifyBlobOptions *utils.VerifyBlobOptions + VerifyBlobOptions *signing.VerifyBlobOptions } // VerificationStrategy describes a strategy for determining whether to verify a package. @@ -114,13 +115,13 @@ func LoadFromDir(ctx context.Context, dirPath string, opts PackageLayoutOptions) // Only applies when VerifyBlobOptions is not already set, // ensuring the new API takes precedence over the deprecated field. if opts.VerifyBlobOptions == nil && opts.PublicKeyPath != "" { - defaults := utils.DefaultVerifyBlobOptions() + defaults := signing.DefaultVerifyBlobOptions() defaults.Key = opts.PublicKeyPath opts.VerifyBlobOptions = &defaults } if opts.VerificationStrategy < VerifyNever { - verifyOptions := utils.DefaultVerifyBlobOptions() + verifyOptions := signing.DefaultVerifyBlobOptions() if opts.VerifyBlobOptions != nil { verifyOptions = *opts.VerifyBlobOptions } @@ -166,7 +167,7 @@ func (p *PackageLayout) ContainsSBOM() bool { // SignPackage signs the zarf package using cosign with the provided options. // If the options do not indicate signing should be performed (no key material configured), // this is a no-op and returns nil. -func (p *PackageLayout) SignPackage(ctx context.Context, opts utils.SignBlobOptions) (err error) { +func (p *PackageLayout) SignPackage(ctx context.Context, opts signing.SignBlobOptions) (err error) { // Note: This function: // 1. Updates Pkg.Build.Signed = true in memory // 2. Writes the updated zarf.yaml (with signed:true) to a temporary file @@ -223,12 +224,17 @@ func (p *PackageLayout) SignPackage(ctx context.Context, opts utils.SignBlobOpti tmpSignaturePath := filepath.Join(tmpDir, Signature) tmpBundlePath := filepath.Join(tmpDir, Bundle) - // Update in-memory state to signed:true + // Update in-memory state signed := true p.Pkg.Build.Signed = &signed - // Save original provenance files for rollback + // Save original fields for rollback originalProvenanceFiles := slices.Clone(p.Pkg.Build.ProvenanceFiles) + originalVersionRequirements := slices.Clone(p.Pkg.Build.VersionRequirements) + + // Keyless signatures require bundle format — the cert chain cannot be stored in the + // legacy .sig file. For key-based signing, respect the BundleSignature feature flag. + bundleEnabled := feature.IsEnabled(feature.BundleSignature) || opts.Keyless // Append signature files to the provenance files list. // These are created after checksum generation and cannot be in checksums.txt. @@ -237,59 +243,61 @@ func (p *PackageLayout) SignPackage(ctx context.Context, opts utils.SignBlobOpti if !slices.Contains(p.Pkg.Build.ProvenanceFiles, Signature) { p.Pkg.Build.ProvenanceFiles = append(p.Pkg.Build.ProvenanceFiles, Signature) } - - if feature.IsEnabled(feature.BundleSignature) && !slices.Contains(p.Pkg.Build.ProvenanceFiles, Bundle) { + if bundleEnabled && !slices.Contains(p.Pkg.Build.ProvenanceFiles, Bundle) { p.Pkg.Build.ProvenanceFiles = append(p.Pkg.Build.ProvenanceFiles, Bundle) + p.Pkg.Build.VersionRequirements = append(p.Pkg.Build.VersionRequirements, v1alpha1.VersionRequirement{ + Version: "v0.71.0", + Reason: "This package contains a bundle format signature which requires Zarf v0.71.0 or later", + }) } // Marshal package with signed:true b, err := goyaml.Marshal(p.Pkg) if err != nil { - // Rollback p.Pkg.Build.Signed = originalSigned p.Pkg.Build.ProvenanceFiles = originalProvenanceFiles + p.Pkg.Build.VersionRequirements = originalVersionRequirements return fmt.Errorf("failed to marshal package for signing: %w", err) } // Write to temporary file err = os.WriteFile(tmpZarfYAMLPath, b, helpers.ReadWriteUser) if err != nil { - // Rollback p.Pkg.Build.Signed = originalSigned p.Pkg.Build.ProvenanceFiles = originalProvenanceFiles + p.Pkg.Build.VersionRequirements = originalVersionRequirements return fmt.Errorf("failed to write temp %s: %w", ZarfYAML, err) } // Configure signing to write to temp directory signOpts := opts + signOpts.NewBundleFormat = bundleEnabled // Validate outputs before setting temporary paths actualSignaturePath := filepath.Join(p.dirPath, Signature) actualBundlePath := filepath.Join(p.dirPath, Bundle) signOpts.OutputSignature = actualSignaturePath - if feature.IsEnabled(feature.BundleSignature) { + if bundleEnabled { signOpts.BundlePath = actualBundlePath - } else { - signOpts.NewBundleFormat = false - signOpts.BundlePath = "" } + err = signOpts.CheckOverwrite(ctx) if err != nil { return err } signOpts.OutputSignature = tmpSignaturePath - if feature.IsEnabled(feature.BundleSignature) { + if bundleEnabled { signOpts.BundlePath = tmpBundlePath } // Perform the signing operation on the temp file l.Debug("signing package", "source", tmpZarfYAMLPath, "signature", tmpSignaturePath) - _, err = utils.CosignSignBlobWithOptions(ctx, tmpZarfYAMLPath, signOpts) + _, err = signing.CosignSignBlobWithOptions(ctx, tmpZarfYAMLPath, signOpts) if err != nil { - // Rollback in-memory state p.Pkg.Build.Signed = originalSigned p.Pkg.Build.ProvenanceFiles = originalProvenanceFiles + p.Pkg.Build.VersionRequirements = originalVersionRequirements return fmt.Errorf("failed to sign package: %w", err) } @@ -309,19 +317,28 @@ func (p *PackageLayout) SignPackage(ctx context.Context, opts utils.SignBlobOpti return fmt.Errorf("failed to move signature after signing: %w", err) } - if feature.IsEnabled(feature.BundleSignature) { - err = os.Rename(tmpBundlePath, actualBundlePath) - if err != nil { + if bundleEnabled { + if err = os.Rename(tmpBundlePath, actualBundlePath); err != nil { return fmt.Errorf("failed to move bundle after signing: %w", err) } } + if bundleEnabled { + if info, bundleErr := signing.ReadBundleInfo(actualBundlePath); bundleErr == nil { + if info.Identity != "" { + l.Info("signed package keyless", "identity", info.Identity, "issuer", info.Issuer) + } + } else { + l.Debug("could not read bundle info after signing", "error", bundleErr) + } + } + l.Info("package signed successfully") return nil } // VerifyPackageSignature verifies the package signature -func (p *PackageLayout) VerifyPackageSignature(ctx context.Context, opts utils.VerifyBlobOptions) error { +func (p *PackageLayout) VerifyPackageSignature(ctx context.Context, opts signing.VerifyBlobOptions) error { l := logger.From(ctx) l.Debug("verifying package signature") @@ -335,59 +352,83 @@ func (p *PackageLayout) VerifyPackageSignature(ctx context.Context, opts utils.V return fmt.Errorf("invalid package layout: %s is not a directory", p.dirPath) } - // Sync the deprecated KeyRef alias before the gate so a caller using only the - // old field name isn't rejected for missing material. CosignVerifyBlobWithOptions - // emits the deprecation warning when invoked. Touching the deprecated field is - // the only way to perform the migration sync from this package. + // Sync the deprecated KeyRef alias before computing hasKey so callers using + // only KeyRef are not rejected for missing material. CosignVerifyBlobWithOptions + // emits the deprecation warning when invoked. if opts.Key == "" && opts.KeyRef != "" { //nolint:staticcheck // intentional read of deprecated alias for migration sync opts.Key = opts.KeyRef //nolint:staticcheck // intentional read of deprecated alias for migration sync } + hasKey := opts.Key != "" + hasKeylessIdentity := opts.CertVerify.CertIdentity != "" || opts.CertVerify.CertIdentityRegexp != "" + hasCert := opts.CertVerify.Cert != "" + hasVerificationMaterial := hasKey || hasKeylessIdentity || hasCert + // Handle the case where the package is not signed if !p.IsSigned() { - // Note: add future logic for verification material here - if opts.Key != "" { - return errors.New("a key was provided but the package is not signed") + if hasVerificationMaterial { + return errors.New("verification material was provided but the package is not signed") } - return errors.New("package is not signed - verification cannot be performed") } - // Validate that we have required verification material - // Note: this will later be replaced when verification enhancements are made - if opts.Key == "" { - return errors.New("package is signed but no verification material was provided (Public Key, etc.)") + // Check for bundle format signature (preferred). Parse it once for both method + // detection (fast-fail below) and the verify path. + bundlePath := filepath.Join(p.dirPath, Bundle) + bundleInfo, bundleErr := signing.ReadBundleInfo(bundlePath) + hasBundleInfo := bundleErr == nil + + // Early validation: fail fast with a method-specific message before cosign emits a generic error. + if hasBundleInfo { + switch bundleInfo.Method { + case "keyless": + if !hasKeylessIdentity && !hasCert { + return errors.New("package was signed with keyless method; provide --certificate-identity + --certificate-oidc-issuer to verify") + } + case "key": + if !hasKey && !hasCert { + return errors.New("package was signed with a key; provide --key to verify") + } + } } - // Check for bundle format signature (preferred) - bundlePath := filepath.Join(p.dirPath, Bundle) - _, err := os.Stat(bundlePath) - if err == nil { + if !hasVerificationMaterial { + return errors.New("package is signed but no verification material was provided (--key, --certificate-identity + --certificate-oidc-issuer, or --certificate)") + } + + if hasBundleInfo { opts.BundlePath = bundlePath ZarfYAMLPath := filepath.Join(p.dirPath, ZarfYAML) - return utils.CosignVerifyBlobWithOptions(ctx, ZarfYAMLPath, opts) + return signing.CosignVerifyBlobWithOptions(ctx, ZarfYAMLPath, opts) } - if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("error checking bundle signature: %w", err) + if !errors.Is(bundleErr, os.ErrNotExist) { + return fmt.Errorf("error checking bundle signature: %w", bundleErr) } // Bundle doesn't exist, check for legacy signature format signaturePath := filepath.Join(p.dirPath, Signature) - _, err = os.Stat(signaturePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { + _, sigStatErr := os.Stat(signaturePath) + if sigStatErr != nil { + if errors.Is(sigStatErr, os.ErrNotExist) { return fmt.Errorf("signature not found: neither bundle nor legacy signature exists") } - return fmt.Errorf("error checking legacy signature: %w", err) + return fmt.Errorf("error checking legacy signature: %w", sigStatErr) + } + + // Legacy signatures don't carry a certificate chain, so keyless identity + // verification has nothing to match against. Fail fast with a clear message + // rather than letting cosign emit a generic key/cert/bundle error. + if hasKeylessIdentity { + return errors.New("keyless verification requires bundle-format signatures, but this package has only a legacy .sig. Ask the publisher to re-sign with bundle format, or verify with --key") } // Legacy signature found - l.Warn("bundle format signature not found: legacy signature is being deprecated. consider resigning this zarf package with the --features='bundle-signature=true' flag.") + l.Warn("bundle format signature not found: legacy signature is being deprecated.") opts.Signature = signaturePath opts.CommonVerifyOptions.NewBundleFormat = false ZarfYAMLPath := filepath.Join(p.dirPath, ZarfYAML) - return utils.CosignVerifyBlobWithOptions(ctx, ZarfYAMLPath, opts) + return signing.CosignVerifyBlobWithOptions(ctx, ZarfYAMLPath, opts) } // IsSigned returns true if the package is signed. diff --git a/src/pkg/packager/layout/package_test.go b/src/pkg/packager/layout/package_test.go index 86105aeaf4..0418ca9d44 100644 --- a/src/pkg/packager/layout/package_test.go +++ b/src/pkg/packager/layout/package_test.go @@ -16,12 +16,12 @@ import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/pkg/feature" - "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/test/testutil" ) -func verifyOptsFromKey(keyPath string) *utils.VerifyBlobOptions { - opts := utils.DefaultVerifyBlobOptions() +func verifyOptsFromKey(keyPath string) *signing.VerifyBlobOptions { + opts := signing.DefaultVerifyBlobOptions() opts.Key = keyPath return &opts } @@ -242,7 +242,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -267,7 +267,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "wrongpassword" @@ -285,7 +285,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -300,7 +300,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -315,7 +315,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -340,7 +340,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" opts.Overwrite = true @@ -372,7 +372,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { } // Empty options - no signing key material configured - opts := utils.SignBlobOptions{} + opts := signing.SignBlobOptions{} // Should skip signing without error err = pkgLayout.SignPackage(ctx, opts) @@ -393,7 +393,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -414,7 +414,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" opts.OutputSignature = "/some/custom/path.sig" @@ -443,7 +443,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { } // Wrong password should cause signing to fail - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "wrongpassword" @@ -472,7 +472,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { } // Empty options - should skip signing - opts := utils.SignBlobOptions{} + opts := signing.SignBlobOptions{} err = pkgLayout.SignPackage(ctx, opts) require.NoError(t, err) @@ -510,7 +510,7 @@ func TestPackageLayoutSignPackage(t *testing.T) { require.NoError(t, err) // Sign the package - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -547,14 +547,14 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { tests := []struct { name string - setupFunc func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) + setupFunc func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) expectedErr string expectSigned bool expectSignFile bool }{ { name: "package with existing false Signed value gets updated on success", - setupFunc: func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) { + setupFunc: func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, ZarfYAML) require.NoError(t, os.WriteFile(yamlPath, []byte("foobar"), 0o644)) @@ -569,7 +569,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -581,7 +581,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, { name: "package with existing true Signed value gets overwritten", - setupFunc: func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) { + setupFunc: func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, ZarfYAML) require.NoError(t, os.WriteFile(yamlPath, []byte("foobar"), 0o644)) @@ -596,7 +596,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -608,7 +608,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, { name: "sign with different password-protected key", - setupFunc: func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) { + setupFunc: func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, ZarfYAML) require.NoError(t, os.WriteFile(yamlPath, []byte("test content"), 0o644)) @@ -618,7 +618,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -630,7 +630,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, { name: "passFunc returns error", - setupFunc: func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) { + setupFunc: func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, ZarfYAML) require.NoError(t, os.WriteFile(yamlPath, []byte("foobar"), 0o644)) @@ -643,7 +643,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { passFunc := cosign.PassFunc(func(_ bool) ([]byte, error) { return nil, os.ErrPermission }) - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.PassFunc = passFunc @@ -655,7 +655,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, { name: "empty package metadata still signs", - setupFunc: func(t *testing.T) (*PackageLayout, utils.SignBlobOptions) { + setupFunc: func(t *testing.T) (*PackageLayout, signing.SignBlobOptions) { tmpDir := t.TempDir() yamlPath := filepath.Join(tmpDir, ZarfYAML) require.NoError(t, os.WriteFile(yamlPath, []byte("foobar"), 0o644)) @@ -668,7 +668,7 @@ func TestPackageLayoutSignPackageValidation(t *testing.T) { }, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -734,7 +734,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign the package (legacy only, bundle feature disabled by default) - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -743,7 +743,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { require.FileExists(t, legacySignaturePath, "legacy signature should exist") // Verify the signature (should use legacy format) - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -764,7 +764,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign with the test key - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -772,7 +772,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { require.NoError(t, err) // Try to verify with a different (non-existent) key - should fail - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/nonexistent.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -792,12 +792,12 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) require.Error(t, err) - require.Contains(t, err.Error(), "a key was provided but the package is not signed") + require.Contains(t, err.Error(), "verification material was provided but the package is not signed") }) t.Run("verification fails with empty dirPath", func(t *testing.T) { @@ -806,7 +806,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err := pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -819,7 +819,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err := pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -838,7 +838,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -860,7 +860,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign the package - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -868,11 +868,11 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { require.NoError(t, err) // Try to verify without providing a key - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "" // Empty key err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) - require.EqualError(t, err, "package is signed but no verification material was provided (Public Key, etc.)") + require.EqualError(t, err, "package was signed with a key; provide --key to verify") }) t.Run("verification fails when signature is corrupted", func(t *testing.T) { @@ -889,7 +889,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign the package - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -906,7 +906,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Try to verify with corrupted signature(s) - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -927,7 +927,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign the package - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -939,7 +939,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { require.NoError(t, err) // Verification should fail because content doesn't match signature - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -962,7 +962,7 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { } // Sign the package - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -975,12 +975,43 @@ func TestPackageLayoutVerifyPackageSignature(t *testing.T) { require.NoError(t, err) // Verification should work with legacy signature (fallback path) - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) require.NoError(t, err, "verification should succeed with legacy signature format") }) + + t.Run("deprecated KeyRef alias resolves before hasKey is computed", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + yamlPath := filepath.Join(tmpDir, ZarfYAML) + + err := os.WriteFile(yamlPath, []byte("test content"), 0o644) + require.NoError(t, err) + + pkgLayout := &PackageLayout{ + dirPath: tmpDir, + Pkg: v1alpha1.ZarfPackage{}, + } + + signOpts := signing.DefaultSignBlobOptions() + signOpts.Key = "./testdata/cosign.key" + signOpts.Password = "test" + + err = pkgLayout.SignPackage(ctx, signOpts) + require.NoError(t, err) + + // Use only the deprecated KeyRef alias with Key intentionally left empty. + // Without the ordering fix, hasKey was computed before the KeyRef→Key sync, + // causing verification to fail with "package was signed with a key; provide --key to verify". + verifyOpts := signing.DefaultVerifyBlobOptions() + verifyOpts.KeyRef = "./testdata/cosign.pub" //nolint:staticcheck + + err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) + require.NoError(t, err) + }) } // TestSignPackageBundleSignatureEnabled tests signing behavior when the BundleSignature @@ -1009,7 +1040,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -1046,7 +1077,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { err = os.WriteFile(yamlPath, b, 0o644) require.NoError(t, err) - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -1078,7 +1109,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -1086,7 +1117,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { require.NoError(t, err) require.FileExists(t, bundlePath) - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -1107,7 +1138,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { Pkg: v1alpha1.ZarfPackage{}, } - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -1120,7 +1151,7 @@ func TestSignPackageBundleSignatureEnabled(t *testing.T) { err = os.Remove(bundlePath) require.NoError(t, err) - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = "./testdata/cosign.pub" err = pkgLayout.VerifyPackageSignature(ctx, verifyOpts) @@ -1311,7 +1342,7 @@ func TestLoadFromDir_VerificationStrategies(t *testing.T) { Pkg: pkg, } - signOpts := utils.DefaultSignBlobOptions() + signOpts := signing.DefaultSignBlobOptions() signOpts.Key = "./testdata/cosign.key" signOpts.Password = "test" @@ -1667,7 +1698,7 @@ func TestSignPackage_PopulatesProvenanceFiles(t *testing.T) { }, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "test" @@ -1695,7 +1726,7 @@ func TestSignPackage_PopulatesProvenanceFiles(t *testing.T) { }, } - opts := utils.DefaultSignBlobOptions() + opts := signing.DefaultSignBlobOptions() opts.Key = "./testdata/cosign.key" opts.Password = "wrongpassword" diff --git a/src/pkg/packager/load.go b/src/pkg/packager/load.go index 97b1293332..47ba66ba70 100644 --- a/src/pkg/packager/load.go +++ b/src/pkg/packager/load.go @@ -21,6 +21,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/cluster" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/layout" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/state" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/pkg/zoci" @@ -33,7 +34,7 @@ type LoadOptions struct { Architecture string // Deprecated: Use VerifyBlobOptions instead. PublicKeyPath string - VerifyBlobOptions *utils.VerifyBlobOptions + VerifyBlobOptions *signing.VerifyBlobOptions Filter filters.ComponentFilterStrategy Output string // number of layers to pull in parallel @@ -63,7 +64,7 @@ func LoadPackage(ctx context.Context, source string, opts LoadOptions) (_ *layou // Only applies when VerifyBlobOptions is not already set, // ensuring the new API takes precedence over the deprecated field. if opts.VerifyBlobOptions == nil && opts.PublicKeyPath != "" { - defaults := utils.DefaultVerifyBlobOptions() + defaults := signing.DefaultVerifyBlobOptions() defaults.Key = opts.PublicKeyPath opts.VerifyBlobOptions = &defaults } diff --git a/src/pkg/packager/load_test.go b/src/pkg/packager/load_test.go index 409980bbb7..ce1e2a3a11 100644 --- a/src/pkg/packager/load_test.go +++ b/src/pkg/packager/load_test.go @@ -17,7 +17,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/cluster" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/layout" - "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/test/testutil" ) @@ -69,7 +69,7 @@ func TestLoadPackage(t *testing.T) { tarPath := filepath.Join("testdata", "load-package", "compressed", "zarf-package-test-amd64-0.0.1.tar.zst") keyPath := filepath.Join("layout", "testdata", "cosign.pub") - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = keyPath // VerifyNever should skip verification entirely and succeed diff --git a/src/pkg/packager/publish.go b/src/pkg/packager/publish.go index c6369c8495..035cfd63e2 100644 --- a/src/pkg/packager/publish.go +++ b/src/pkg/packager/publish.go @@ -12,6 +12,7 @@ import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/pkg/logger" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/pkg/zoci" "github.com/zarf-dev/zarf/src/types" @@ -107,15 +108,18 @@ func PublishFromOCI(ctx context.Context, src registry.Reference, dst registry.Re type PublishPackageOptions struct { // OCIConcurrency configures the amount of layers to push in parallel OCIConcurrency int - // SigningKeyPath points to a signing key on the local disk. - SigningKeyPath string - // SigningKeyPassword holds a password to use the key at SigningKeyPath. - SigningKeyPassword string + // SignBlobOptions holds all signing configuration. Use signing.DefaultSignBlobOptions() as a base. + SignBlobOptions signing.SignBlobOptions // Retries specifies the number of retries to use Retries int types.RemoteOptions // Tag is an optional tag for the OCI reference separate from the package metadata.version Tag string + + // Deprecated: populate SignBlobOptions.Key directly. + SigningKeyPath string + // Deprecated: populate SignBlobOptions.Password directly. + SigningKeyPassword string } // PublishPackage takes a package layout and pushes the package to the given registry. @@ -141,14 +145,14 @@ func PublishPackage(ctx context.Context, pkgLayout *layout.PackageLayout, dst re return registry.Reference{}, fmt.Errorf("package layout must be specified") } - // Sign the package with the provided options - signOpts := utils.DefaultSignBlobOptions() - signOpts.Key = opts.SigningKeyPath - signOpts.Password = opts.SigningKeyPassword - // Publish never re-writes the tarball content - overwrite explicitly - signOpts.Overwrite = true + if opts.SigningKeyPath != "" && opts.SignBlobOptions.Key == "" { + opts.SignBlobOptions.Key = opts.SigningKeyPath + } + if opts.SigningKeyPassword != "" && opts.SignBlobOptions.Password == "" { + opts.SignBlobOptions.Password = opts.SigningKeyPassword + } - if err := pkgLayout.SignPackage(ctx, signOpts); err != nil { + if err := pkgLayout.SignPackage(ctx, opts.SignBlobOptions); err != nil { return registry.Reference{}, fmt.Errorf("unable to sign package: %w", err) } diff --git a/src/pkg/packager/publish_test.go b/src/pkg/packager/publish_test.go index c79927ae3d..9999e3cc10 100644 --- a/src/pkg/packager/publish_test.go +++ b/src/pkg/packager/publish_test.go @@ -17,7 +17,7 @@ import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/layout" - "github.com/zarf-dev/zarf/src/pkg/utils" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/zoci" "github.com/zarf-dev/zarf/src/test/testutil" "github.com/zarf-dev/zarf/src/types" @@ -36,7 +36,7 @@ func defaultTestRemoteOptions() types.RemoteOptions { func pullFromRemote(ctx context.Context, t *testing.T, packageRef string, architecture string, publicKeyPath string, cachePath string) *layout.PackageLayout { t.Helper() - verifyOpts := utils.DefaultVerifyBlobOptions() + verifyOpts := signing.DefaultVerifyBlobOptions() verifyOpts.Key = publicKeyPath // Generate tmpdir and pull published package from local registry @@ -221,6 +221,10 @@ func TestPublishSkeleton(t *testing.T) { } func TestPublishPackage(t *testing.T) { + signOpts := signing.DefaultSignBlobOptions() + signOpts.Key = filepath.Join("testdata", "publish", "cosign.key") + signOpts.Password = "password" + tt := []struct { name string path string @@ -240,9 +244,8 @@ func TestPublishPackage(t *testing.T) { name: "Sign and publish package", path: filepath.Join("testdata", "load-package", "compressed", "zarf-package-test-amd64-0.0.1.tar.zst"), opts: PublishPackageOptions{ - RemoteOptions: defaultTestRemoteOptions(), - SigningKeyPath: filepath.Join("testdata", "publish", "cosign.key"), - SigningKeyPassword: "password", + RemoteOptions: defaultTestRemoteOptions(), + SignBlobOptions: signOpts, }, publicKeyPath: filepath.Join("testdata", "publish", "cosign.pub"), expectedTag: "0.0.1", @@ -279,7 +282,7 @@ func TestPublishPackage(t *testing.T) { //build data changes when signed layoutActual.Pkg.Build = v1alpha1.ZarfBuildData{} require.Equal(t, layoutExpected.Pkg, layoutActual.Pkg, "Uploaded package is not identical to downloaded package") - if tc.opts.SigningKeyPath != "" { + if tc.opts.SignBlobOptions.Key != "" { require.FileExists(t, filepath.Join(layoutActual.DirPath(), layout.Signature)) } }) diff --git a/src/pkg/packager/pull.go b/src/pkg/packager/pull.go index 08725280f1..eaa9ea0be3 100644 --- a/src/pkg/packager/pull.go +++ b/src/pkg/packager/pull.go @@ -16,6 +16,7 @@ import ( "time" "github.com/zarf-dev/zarf/src/pkg/logger" + "github.com/zarf-dev/zarf/src/pkg/signing" "github.com/zarf-dev/zarf/src/pkg/utils" "github.com/zarf-dev/zarf/src/types" @@ -39,7 +40,7 @@ type PullOptions struct { // Deprecated: Use VerifyBlobOptions instead. PublicKeyPath validates the create-time signage of a package. PublicKeyPath string // VerifyBlobOptions configures package signature verification. - VerifyBlobOptions *utils.VerifyBlobOptions + VerifyBlobOptions *signing.VerifyBlobOptions // OCIConcurrency is the number of layers pulled in parallel OCIConcurrency int // CachePath is used to cache layers from OCI package pulls @@ -80,7 +81,7 @@ func Pull(ctx context.Context, source, destination string, opts PullOptions) (_ // Only applies when VerifyBlobOptions is not already set, // ensuring the new API takes precedence over the deprecated field. if opts.VerifyBlobOptions == nil && opts.PublicKeyPath != "" { - defaults := utils.DefaultVerifyBlobOptions() + defaults := signing.DefaultVerifyBlobOptions() defaults.Key = opts.PublicKeyPath opts.VerifyBlobOptions = &defaults } @@ -118,7 +119,7 @@ type pullOCIOptions struct { Filter filters.ComponentFilterStrategy OCIConcurrency int CachePath string - VerifyBlobOptions *utils.VerifyBlobOptions + VerifyBlobOptions *signing.VerifyBlobOptions Connected bool types.RemoteOptions layout.VerificationStrategy diff --git a/src/pkg/signing/bundle.go b/src/pkg/signing/bundle.go new file mode 100644 index 0000000000..c55acda744 --- /dev/null +++ b/src/pkg/signing/bundle.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package signing + +import ( + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + + "github.com/sigstore/sigstore-go/pkg/bundle" +) + +// Sigstore custom OIDs for the OIDC issuer claim embedded in Fulcio certs. +// - sigstoreIssuerOIDLegacy: raw string in extension value +// - sigstoreIssuerOIDV2: DER-encoded UTF8String, used by Fulcio v1+ +var ( + sigstoreIssuerOIDLegacy = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1} + sigstoreIssuerOIDV2 = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8} +) + +// extractIdentityFromCert extracts the signer identity (cert SAN) and OIDC issuer +// from a Fulcio-issued X.509 certificate using Sigstore OID extensions. +// SAN priority: email > URI > DNS. OID priority: V2 > legacy. +func extractIdentityFromCert(cert *x509.Certificate) (identity, issuer string) { + switch { + case len(cert.EmailAddresses) > 0: + identity = cert.EmailAddresses[0] + case len(cert.URIs) > 0: + identity = cert.URIs[0].String() + case len(cert.DNSNames) > 0: + identity = cert.DNSNames[0] + } + + for _, ext := range cert.Extensions { + switch { + case ext.Id.Equal(sigstoreIssuerOIDV2): + var s string + if _, decErr := asn1.Unmarshal(ext.Value, &s); decErr == nil { + issuer = s + return identity, issuer + } + case ext.Id.Equal(sigstoreIssuerOIDLegacy) && issuer == "": + issuer = string(ext.Value) + } + } + + return identity, issuer +} + +// BundleInfo contains parsed metadata from a Sigstore bundle file. +type BundleInfo struct { + // Method is "keyless" for Fulcio-issued certificate bundles, "key" for public-key bundles. + Method string + Identity string // cert SAN — empty for key-based signatures + Issuer string // OIDC issuer — empty for key-based signatures +} + +// ReadBundleInfo parses a Sigstore bundle file and returns its signing metadata. +func ReadBundleInfo(bundlePath string) (BundleInfo, error) { + b, err := bundle.LoadJSONFromPath(bundlePath) + if err != nil { + return BundleInfo{}, fmt.Errorf("loading bundle: %w", err) + } + vc, err := b.VerificationContent() + if err != nil { + return BundleInfo{}, fmt.Errorf("reading verification content: %w", err) + } + switch v := vc.(type) { + case *bundle.Certificate: + identity, issuer := extractIdentityFromCert(v.Certificate()) + return BundleInfo{Method: "keyless", Identity: identity, Issuer: issuer}, nil + case *bundle.PublicKey: + return BundleInfo{Method: "key"}, nil + default: + return BundleInfo{}, fmt.Errorf("unrecognised verification content type %T", vc) + } +} + +// ReadKeylessIdentityFromBundle parses a Sigstore bundle file and returns the +// signer identity (cert SAN) and OIDC issuer claim. Returns an error if the +// bundle does not contain a certificate (i.e. is not a keyless signature). +func ReadKeylessIdentityFromBundle(bundlePath string) (identity, issuer string, err error) { + info, err := ReadBundleInfo(bundlePath) + if err != nil { + return "", "", err + } + if info.Method != "keyless" { + return "", "", errors.New("bundle does not contain a certificate (not a keyless signature)") + } + return info.Identity, info.Issuer, nil +} diff --git a/src/pkg/signing/bundle_test.go b/src/pkg/signing/bundle_test.go new file mode 100644 index 0000000000..0d84064bff --- /dev/null +++ b/src/pkg/signing/bundle_test.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package signing + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "math/big" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// makeCert creates a self-signed test certificate from the given template. +func makeCert(t *testing.T, tmpl *x509.Certificate) *x509.Certificate { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl.SerialNumber = big.NewInt(1) + tmpl.NotBefore = time.Now() + tmpl.NotAfter = time.Now().Add(time.Hour) + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +// writeBundleFixture writes a minimal sigstore-go-valid bundle containing cert. +// shape: "x509CertificateChain" or "certificate". +func writeBundleFixture(t *testing.T, cert *x509.Certificate, shape string) string { + t.Helper() + var verMaterial map[string]any + switch shape { + case "certificate": + verMaterial = map[string]any{ + "certificate": map[string]any{ + "rawBytes": base64.StdEncoding.EncodeToString(cert.Raw), + }, + } + case "x509CertificateChain": + verMaterial = map[string]any{ + "x509CertificateChain": map[string]any{ + "certificates": []map[string]any{ + {"rawBytes": base64.StdEncoding.EncodeToString(cert.Raw)}, + }, + }, + } + default: + t.Fatalf("unknown bundle shape: %s", shape) + } + b := map[string]any{ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.1", + "verificationMaterial": verMaterial, + "messageSignature": map[string]any{ + "messageDigest": map[string]any{ + "algorithm": "SHA2_256", + "digest": base64.StdEncoding.EncodeToString(make([]byte, 32)), + }, + "signature": base64.StdEncoding.EncodeToString([]byte("dummy")), + }, + } + data, err := json.Marshal(b) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "zarf.bundle.json") + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +// writeKeyBasedBundleFixture writes a minimal sigstore-go-valid bundle using a +// public key hint (not a certificate), representing a key-based signature. +func writeKeyBasedBundleFixture(t *testing.T) string { + t.Helper() + b := map[string]any{ + "mediaType": "application/vnd.dev.sigstore.bundle+json;version=0.1", + "verificationMaterial": map[string]any{ + "publicKey": map[string]any{"hint": "test-key-id"}, + }, + "messageSignature": map[string]any{ + "messageDigest": map[string]any{ + "algorithm": "SHA2_256", + "digest": base64.StdEncoding.EncodeToString(make([]byte, 32)), + }, + "signature": base64.StdEncoding.EncodeToString([]byte("dummy")), + }, + } + data, err := json.Marshal(b) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "zarf.bundle.json") + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +func TestExtractIdentityFromCert(t *testing.T) { + t.Parallel() + + t.Run("email SAN with V2 issuer OID", func(t *testing.T) { + t.Parallel() + issuerVal, err := asn1.Marshal("https://oauth2.sigstore.dev/auth") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + EmailAddresses: []string{"signer@example.com"}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDV2, Value: issuerVal}, + }, + }) + + identity, issuer := extractIdentityFromCert(cert) + require.Equal(t, "signer@example.com", identity) + require.Equal(t, "https://oauth2.sigstore.dev/auth", issuer) + }) + + t.Run("URI SAN with legacy issuer OID", func(t *testing.T) { + t.Parallel() + ghaURI, err := url.Parse("https://github.com/example/repo/.github/workflows/release.yml@refs/heads/main") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + URIs: []*url.URL{ghaURI}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDLegacy, Value: []byte("https://token.actions.githubusercontent.com")}, + }, + }) + + identity, issuer := extractIdentityFromCert(cert) + require.Equal(t, ghaURI.String(), identity) + require.Equal(t, "https://token.actions.githubusercontent.com", issuer) + }) + + t.Run("V2 OID takes precedence over legacy when both present", func(t *testing.T) { + t.Parallel() + v2Val, err := asn1.Marshal("https://v2-issuer.example.com") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + EmailAddresses: []string{"signer@example.com"}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDLegacy, Value: []byte("https://legacy.example.com")}, + {Id: sigstoreIssuerOIDV2, Value: v2Val}, + }, + }) + + _, issuer := extractIdentityFromCert(cert) + require.Equal(t, "https://v2-issuer.example.com", issuer) + }) + + t.Run("DNS SAN used when no email or URI", func(t *testing.T) { + t.Parallel() + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + DNSNames: []string{"host.example.com"}, + }) + + identity, _ := extractIdentityFromCert(cert) + require.Equal(t, "host.example.com", identity) + }) +} + +func TestReadBundleInfo(t *testing.T) { + t.Parallel() + + t.Run("certificate bundle returns keyless method with identity and issuer", func(t *testing.T) { + t.Parallel() + issuerVal, err := asn1.Marshal("https://github.com/login/oauth") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + EmailAddresses: []string{"signer@example.com"}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDV2, Value: issuerVal}, + }, + }) + path := writeBundleFixture(t, cert, "certificate") + + info, err := ReadBundleInfo(path) + require.NoError(t, err) + require.Equal(t, "keyless", info.Method) + require.Equal(t, "signer@example.com", info.Identity) + require.Equal(t, "https://github.com/login/oauth", info.Issuer) + }) + + t.Run("key-based bundle returns key method with empty identity and issuer", func(t *testing.T) { + t.Parallel() + path := writeKeyBasedBundleFixture(t) + + info, err := ReadBundleInfo(path) + require.NoError(t, err) + require.Equal(t, "key", info.Method) + require.Empty(t, info.Identity) + require.Empty(t, info.Issuer) + }) + + t.Run("missing bundle file errors", func(t *testing.T) { + t.Parallel() + _, err := ReadBundleInfo(filepath.Join(t.TempDir(), "nonexistent.json")) + require.Error(t, err) + }) +} + +func TestReadKeylessIdentityFromBundle(t *testing.T) { + t.Parallel() + + t.Run("x509CertificateChain bundle returns identity and issuer", func(t *testing.T) { + t.Parallel() + issuerVal, err := asn1.Marshal("https://token.actions.githubusercontent.com") + require.NoError(t, err) + ghaURI, err := url.Parse("https://github.com/example/repo/.github/workflows/release.yml@refs/heads/main") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + URIs: []*url.URL{ghaURI}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDV2, Value: issuerVal}, + }, + }) + path := writeBundleFixture(t, cert, "x509CertificateChain") + + identity, issuer, err := ReadKeylessIdentityFromBundle(path) + require.NoError(t, err) + require.Equal(t, ghaURI.String(), identity) + require.Equal(t, "https://token.actions.githubusercontent.com", issuer) + }) + + t.Run("certificate bundle returns identity and issuer", func(t *testing.T) { + t.Parallel() + issuerVal, err := asn1.Marshal("https://github.com/login/oauth") + require.NoError(t, err) + cert := makeCert(t, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ephemeral"}, + EmailAddresses: []string{"signer@example.com"}, + ExtraExtensions: []pkix.Extension{ + {Id: sigstoreIssuerOIDV2, Value: issuerVal}, + }, + }) + path := writeBundleFixture(t, cert, "certificate") + + identity, issuer, err := ReadKeylessIdentityFromBundle(path) + require.NoError(t, err) + require.Equal(t, "signer@example.com", identity) + require.Equal(t, "https://github.com/login/oauth", issuer) + }) + + t.Run("missing bundle file errors", func(t *testing.T) { + t.Parallel() + _, _, err := ReadKeylessIdentityFromBundle(filepath.Join(t.TempDir(), "nonexistent.json")) + require.Error(t, err) + }) + + t.Run("key-based bundle errors", func(t *testing.T) { + t.Parallel() + path := writeKeyBasedBundleFixture(t) + _, _, err := ReadKeylessIdentityFromBundle(path) + require.ErrorContains(t, err, "not a keyless signature") + }) +} diff --git a/src/pkg/utils/cosign.go b/src/pkg/signing/cosign.go similarity index 81% rename from src/pkg/utils/cosign.go rename to src/pkg/signing/cosign.go index 0fb31ec00c..e50256df5a 100644 --- a/src/pkg/utils/cosign.go +++ b/src/pkg/signing/cosign.go @@ -1,21 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2021-Present The Zarf Authors -// Package utils provides generic utility functions. -package utils +// Package signing provides cosign-based signing and verification for Zarf packages. +package signing import ( "context" + "errors" "fmt" "os" "time" - "github.com/google/go-containerregistry/pkg/name" "github.com/sigstore/cosign/v3/cmd/cosign/cli/options" "github.com/sigstore/cosign/v3/cmd/cosign/cli/sign" + "github.com/sigstore/cosign/v3/cmd/cosign/cli/signcommon" "github.com/sigstore/cosign/v3/cmd/cosign/cli/verify" "github.com/sigstore/cosign/v3/pkg/cosign" - ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" // Register the provider-specific plugins _ "github.com/sigstore/sigstore/pkg/signature/kms/aws" @@ -47,6 +47,11 @@ type SignBlobOptions struct { Password string PassFunc cosign.PassFunc Overwrite bool + // Keyless gates zarf-specific sign-side guards on top of cosign's behavior. + // When true, --signing-key is no longer required and ShouldSign returns true + // even without explicit Key/IDToken/Sk material — cosign resolves identity + // via Fulcio/OIDC at sign time. + Keyless bool // Deprecated: use Key (promoted from the embedded SignBlobOptions). Removed in v1.0. KeyRef string @@ -68,7 +73,7 @@ type VerifyBlobOptions struct { // KeyRef is included for backward compatibility; it's synced to Key in // CosignSignBlobWithOptions. func (opts SignBlobOptions) ShouldSign() bool { - return opts.Key != "" || opts.KeyRef != "" || opts.Fulcio.IdentityToken != "" || opts.SecurityKey.Use + return opts.Key != "" || opts.KeyRef != "" || opts.Fulcio.IdentityToken != "" || opts.SecurityKey.Use || opts.Keyless } // CheckOverwrite errors if any output file exists and Overwrite is false. @@ -88,10 +93,14 @@ func (opts SignBlobOptions) CheckOverwrite(ctx context.Context) error { } // DefaultSignBlobOptions returns SignBlobOptions seeded with zarf defaults. -// Divergence: TlogUpload defaults to false (cosign default true) for airgap. +// Divergences from cosign defaults (air-gap): +// - TlogUpload=false (cosign default true) +// - UseSigningConfig=false (cosign default true) — required because cosign rejects +// UseSigningConfig=true combined with TlogUpload=false. func DefaultSignBlobOptions() SignBlobOptions { var opts SignBlobOptions opts.TlogUpload = false + opts.UseSigningConfig = false opts.Base64Output = true opts.NewBundleFormat = true opts.SecurityKey.Slot = "signature" @@ -176,6 +185,17 @@ func CosignSignBlobWithOptions(ctx context.Context, blobPath string, opts SignBl return nil, err } + // Empty output-path params suppress cosign's deprecation warnings; zarf manages + // OutputSignature internally and the warnings would fire on every sign. + if err := signcommon.LoadTrustedMaterialAndSigningConfig(ctx, &ko, + opts.UseSigningConfig, opts.SigningConfigPath, + opts.Rekor.URL, opts.Fulcio.URL, opts.OIDC.Issuer, opts.TSAServerURL, opts.TrustedRootPath, + opts.TlogUpload, opts.NewBundleFormat, opts.BundlePath, opts.Key, opts.IssueCertificate, + "", "", "", "", "", "", + ); err != nil { + return nil, err + } + l.Debug("signing blob with cosign", "key", opts.Key, "sk", opts.SecurityKey.Use, @@ -203,7 +223,7 @@ func CosignSignBlobWithOptions(ctx context.Context, blobPath string, opts SignBl // CosignVerifyBlobWithOptions verifies a blob via cosign's VerifyBlobCmd. // Mirrors cmd/cosign/cli/verify.go (v3.0.6) VerifyBlob().RunE. -func CosignVerifyBlobWithOptions(ctx context.Context, blobPath string, opts VerifyBlobOptions) error { +func CosignVerifyBlobWithOptions(ctx context.Context, blobPath string, opts VerifyBlobOptions) (err error) { l := logger.From(ctx) if opts.KeyRef != "" { @@ -228,6 +248,19 @@ func CosignVerifyBlobWithOptions(ctx context.Context, blobPath string, opts Veri opts.CommonVerifyOptions.IgnoreTlog = true } + // Keyless verify needs a trusted root. If the user didn't supply --trusted-root, + // fall back to the embedded copy. Key-based and cert-based paths skip this + // entirely so they don't pay for an unused tempfile per verify. + trustedRootPath := opts.CommonVerifyOptions.TrustedRootPath + if trustedRootPath == "" && opts.Key == "" && opts.CertVerify.Cert == "" { + path, cleanup, prepErr := writeEmbeddedTrustedRoot() + if prepErr != nil { + return fmt.Errorf("preparing embedded trusted root: %w", prepErr) + } + defer func() { err = errors.Join(err, cleanup()) }() + trustedRootPath = path + } + ko := options.KeyOpts{ KeyRef: opts.Key, Sk: opts.SecurityKey.Use, @@ -257,7 +290,7 @@ func CosignVerifyBlobWithOptions(ctx context.Context, blobPath string, opts Veri Offline: opts.CommonVerifyOptions.Offline, IgnoreTlog: opts.CommonVerifyOptions.IgnoreTlog, UseSignedTimestamps: opts.CommonVerifyOptions.UseSignedTimestamps, - TrustedRootPath: opts.CommonVerifyOptions.TrustedRootPath, + TrustedRootPath: trustedRootPath, HashAlgorithm: hashAlgorithm, } @@ -280,49 +313,3 @@ func CosignVerifyBlobWithOptions(ctx context.Context, blobPath string, opts Veri l.Debug("blob signature verified successfully") return nil } - -// GetCosignArtifacts returns signatures and attestations for the given image. -func GetCosignArtifacts(image string) ([]string, error) { - var nameOpts []name.Option - - ref, err := name.ParseReference(image, nameOpts...) - if err != nil { - return nil, err - } - - var remoteOpts []ociremote.Option - simg, _ := ociremote.SignedEntity(ref, remoteOpts...) //nolint:errcheck - if simg == nil { - return nil, nil - } - - sigRef, _ := ociremote.SignatureTag(ref, remoteOpts...) //nolint:errcheck - attRef, _ := ociremote.AttestationTag(ref, remoteOpts...) //nolint:errcheck - - ss, err := simg.Signatures() - if err != nil { - return nil, err - } - ssLayers, err := ss.Layers() - if err != nil { - return nil, err - } - - var cosignArtifactList = make([]string, 0) - if 0 < len(ssLayers) { - cosignArtifactList = append(cosignArtifactList, sigRef.String()) - } - - atts, err := simg.Attestations() - if err != nil { - return nil, err - } - aLayers, err := atts.Layers() - if err != nil { - return nil, err - } - if 0 < len(aLayers) { - cosignArtifactList = append(cosignArtifactList, attRef.String()) - } - return cosignArtifactList, nil -} diff --git a/src/pkg/utils/cosign_test.go b/src/pkg/signing/cosign_test.go similarity index 97% rename from src/pkg/utils/cosign_test.go rename to src/pkg/signing/cosign_test.go index f1267f8fc6..cc3fad16c5 100644 --- a/src/pkg/utils/cosign_test.go +++ b/src/pkg/signing/cosign_test.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2021-Present The Zarf Authors -package utils +package signing import ( "testing" diff --git a/src/pkg/signing/embedded_trusted_root.json b/src/pkg/signing/embedded_trusted_root.json new file mode 100644 index 0000000000..effb0a19e6 --- /dev/null +++ b/src/pkg/signing/embedded_trusted_root.json @@ -0,0 +1,126 @@ +{ + "mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1", + "tlogs": [ + { + "baseUrl": "https://rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-01-12T11:53:27Z" + } + }, + "logId": { + "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=" + } + }, + { + "baseUrl": "https://log2025-1.rekor.sigstore.dev", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MCowBQYDK2VwAyEAt8rlp1knGwjfbcXAYPYAkn0XiLz1x8O4t0YkEhie244=", + "keyDetails": "PKIX_ED25519", + "validFor": { + "start": "2025-09-23T00:00:00Z" + } + }, + "logId": { + "keyId": "zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=" + } + } + ], + "certificateAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ==" + } + ] + }, + "validFor": { + "start": "2021-03-07T03:20:29Z", + "end": "2022-12-31T23:59:59.999Z" + } + }, + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore" + }, + "uri": "https://fulcio.sigstore.dev", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ" + } + ] + }, + "validFor": { + "start": "2022-04-13T20:06:15Z" + } + } + ], + "ctlogs": [ + { + "baseUrl": "https://ctfe.sigstore.dev/test", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2021-03-14T00:00:00Z", + "end": "2022-10-31T23:59:59.999Z" + } + }, + "logId": { + "keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I=" + } + }, + { + "baseUrl": "https://ctfe.sigstore.dev/2022", + "hashAlgorithm": "SHA2_256", + "publicKey": { + "rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==", + "keyDetails": "PKIX_ECDSA_P256_SHA_256", + "validFor": { + "start": "2022-10-20T00:00:00Z" + } + }, + "logId": { + "keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4=" + } + } + ], + "timestampAuthorities": [ + { + "subject": { + "organization": "sigstore.dev", + "commonName": "sigstore-tsa-selfsigned" + }, + "uri": "https://timestamp.sigstore.dev/api/v1/timestamp", + "certChain": { + "certificates": [ + { + "rawBytes": "MIICEDCCAZagAwIBAgIUOhNULwyQYe68wUMvy4qOiyojiwwwCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMC4xFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEVMBMGA1UEAxMMc2lnc3RvcmUtdHNhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE4ra2Z8hKNig2T9kFjCAToGG30jky+WQv3BzL+mKvh1SKNR/UwuwsfNCg4sryoYAd8E6isovVA3M4aoNdm9QDi50Z8nTEyvqgfDPtTIwXItfiW/AFf1V7uwkbkAoj0xxco2owaDAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFIn9eUOHz9BlRsMCRscsc1t9tOsDMB8GA1UdIwQYMBaAFJjsAe9/u1H/1JUeb4qImFMHic6/MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMAoGCCqGSM49BAMDA2gAMGUCMDtpsV/6KaO0qyF/UMsX2aSUXKQFdoGTptQGc0ftq1csulHPGG6dsmyMNd3JB+G3EQIxAOajvBcjpJmKb4Nv+2Taoj8Uc5+b6ih6FXCCKraSqupe07zqswMcXJTe1cExvHvvlw==" + }, + { + "rawBytes": "MIIB9zCCAXygAwIBAgIUV7f0GLDOoEzIh8LXSW80OJiUp14wCgYIKoZIzj0EAwMwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZDAeFw0yNTA0MDgwNjU5NDNaFw0zNTA0MDYwNjU5NDNaMDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQUQNtfRT/ou3YATa6wB/kKTe70cfJwyRIBovMnt8RcJph/COE82uyS6FmppLLL1VBPGcPfpQPYJNXzWwi8icwhKQ6W/Qe2h3oebBb2FHpwNJDqo+TMaC/tdfkv/ElJB72jRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSY7AHvf7tR/9SVHm+KiJhTB4nOvzAKBggqhkjOPQQDAwNpADBmAjEAwGEGrfGZR1cen1R8/DTVMI943LssZmJRtDp/i7SfGHmGRP6gRbuj9vOK3b67Z0QQAjEAuT2H673LQEaHTcyQSZrkp4mX7WwkmF+sVbkYY5mXN+RMH13KUEHHOqASaemYWK/E" + } + ] + }, + "validFor": { + "start": "2025-07-04T00:00:00Z" + } + } + ] +} diff --git a/src/pkg/signing/trustedroot.go b/src/pkg/signing/trustedroot.go new file mode 100644 index 0000000000..04f4ee3ba8 --- /dev/null +++ b/src/pkg/signing/trustedroot.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package signing + +import ( + _ "embed" + "errors" + "fmt" + "os" +) + +// embeddedTrustedRoot is the Sigstore TrustedRoot JSON shipped with the binary. +// Refresh before each release with hack/refresh-trusted-root.sh. +// +//go:embed embedded_trusted_root.json +var embeddedTrustedRoot []byte + +// writeEmbeddedTrustedRoot stages the embedded TrustedRoot JSON to a tempfile so +// cosign's VerifyBlobCmd (which only accepts file paths) can consume it. +// Caller must invoke cleanup when done; cleanup returns the os.Remove error. +func writeEmbeddedTrustedRoot() (string, func() error, error) { + f, err := os.CreateTemp("", "zarf-trusted-root-*.json") + if err != nil { + return "", func() error { return nil }, fmt.Errorf("creating tempfile: %w", err) + } + cleanup := func() error { return os.Remove(f.Name()) } + + if _, writeErr := f.Write(embeddedTrustedRoot); writeErr != nil { + closeErr := f.Close() + removeErr := cleanup() + return "", func() error { return nil }, + fmt.Errorf("writing embedded trusted root: %w", errors.Join(writeErr, closeErr, removeErr)) + } + if closeErr := f.Close(); closeErr != nil { + removeErr := cleanup() + return "", func() error { return nil }, + fmt.Errorf("closing embedded trusted root tempfile: %w", errors.Join(closeErr, removeErr)) + } + return f.Name(), cleanup, nil +} diff --git a/src/pkg/signing/trustedroot_test.go b/src/pkg/signing/trustedroot_test.go new file mode 100644 index 0000000000..e35be4ab76 --- /dev/null +++ b/src/pkg/signing/trustedroot_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package signing + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriteEmbeddedTrustedRoot(t *testing.T) { + t.Parallel() + + t.Run("writes valid JSON to a tempfile", func(t *testing.T) { + t.Parallel() + path, cleanup, err := writeEmbeddedTrustedRoot() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, cleanup()) }) + require.NotEmpty(t, path) + + contents, err := os.ReadFile(path) + require.NoError(t, err) + require.NotEmpty(t, contents) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(contents, &parsed)) + require.Equal(t, "application/vnd.dev.sigstore.trustedroot+json;version=0.1", parsed["mediaType"], + "embedded trusted root must be a Sigstore TrustedRoot v0.1 document") + }) + + t.Run("cleanup removes the tempfile", func(t *testing.T) { + t.Parallel() + path, cleanup, err := writeEmbeddedTrustedRoot() + require.NoError(t, err) + require.FileExists(t, path) + + require.NoError(t, cleanup()) + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + }) + + t.Run("each call produces a distinct tempfile", func(t *testing.T) { + t.Parallel() + p1, c1, err := writeEmbeddedTrustedRoot() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, c1()) }) + p2, c2, err := writeEmbeddedTrustedRoot() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, c2()) }) + require.NotEqual(t, p1, p2) + }) +} diff --git a/src/pkg/utils/oci_artifacts.go b/src/pkg/utils/oci_artifacts.go new file mode 100644 index 0000000000..d392d5e318 --- /dev/null +++ b/src/pkg/utils/oci_artifacts.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package utils provides generic utility functions. +package utils + +import ( + "github.com/google/go-containerregistry/pkg/name" + ociremote "github.com/sigstore/cosign/v3/pkg/oci/remote" +) + +// GetCosignArtifacts returns signatures and attestations for the given image. +func GetCosignArtifacts(image string) ([]string, error) { + var nameOpts []name.Option + + ref, err := name.ParseReference(image, nameOpts...) + if err != nil { + return nil, err + } + + var remoteOpts []ociremote.Option + simg, _ := ociremote.SignedEntity(ref, remoteOpts...) //nolint:errcheck + if simg == nil { + return nil, nil + } + + sigRef, _ := ociremote.SignatureTag(ref, remoteOpts...) //nolint:errcheck + attRef, _ := ociremote.AttestationTag(ref, remoteOpts...) //nolint:errcheck + + ss, err := simg.Signatures() + if err != nil { + return nil, err + } + ssLayers, err := ss.Layers() + if err != nil { + return nil, err + } + + var cosignArtifactList = make([]string, 0) + if 0 < len(ssLayers) { + cosignArtifactList = append(cosignArtifactList, sigRef.String()) + } + + atts, err := simg.Attestations() + if err != nil { + return nil, err + } + aLayers, err := atts.Layers() + if err != nil { + return nil, err + } + if 0 < len(aLayers) { + cosignArtifactList = append(cosignArtifactList, attRef.String()) + } + return cosignArtifactList, nil +} diff --git a/src/test/e2e/12_package_signing_test.go b/src/test/e2e/12_package_signing_test.go index 11d5eed835..4399801974 100644 --- a/src/test/e2e/12_package_signing_test.go +++ b/src/test/e2e/12_package_signing_test.go @@ -69,7 +69,7 @@ func TestPackageSigning(t *testing.T) { // try to verify without key (should fail) _, stdErr, err = e2e.Zarf(t, "package", "verify", testPath) require.Error(t, err) - require.Contains(t, stdErr, "no verification material was provided") + require.Contains(t, stdErr, "package was signed with a key; provide --key to verify") }) t.Run("Verify with key but unsigned package fails", func(t *testing.T) { @@ -84,6 +84,6 @@ func TestPackageSigning(t *testing.T) { // try to verify with key but package is not signed (should fail) _, stdErr, err = e2e.Zarf(t, "package", "verify", testPath, "--key", filepath.Join("src", "test", "packages", "zarf-test.pub")) require.Error(t, err) - require.Contains(t, stdErr, "a key was provided but the package is not signed") + require.Contains(t, stdErr, "verification material was provided but the package is not signed") }) } diff --git a/src/test/e2e/31_checksum_and_signature_test.go b/src/test/e2e/31_checksum_and_signature_test.go index be12db76df..f67141d96e 100644 --- a/src/test/e2e/31_checksum_and_signature_test.go +++ b/src/test/e2e/31_checksum_and_signature_test.go @@ -36,7 +36,7 @@ func TestChecksumAndSignature(t *testing.T) { // Test that we get an error when trying to deploy a package without providing the public key stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pkgName, "--verify", "--confirm") require.Error(t, err, stdOut, stdErr) - require.Contains(t, stdErr, "package is signed but no verification material was provided") + require.Contains(t, stdErr, "package was signed with a key; provide --key to verify") // Test that we don't get an error when we remember to provide the public key stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pkgName, publicKeyFlag, "--confirm") diff --git a/src/test/e2e/34_custom_init_package_test.go b/src/test/e2e/34_custom_init_package_test.go index 9fd6f75207..e92feeeb9d 100644 --- a/src/test/e2e/34_custom_init_package_test.go +++ b/src/test/e2e/34_custom_init_package_test.go @@ -38,7 +38,7 @@ func TestCustomInit(t *testing.T) { // Test that we get an error when trying to deploy a package without providing the public key stdOut, stdErr, err = e2e.Zarf(t, "init", "--verify", "--confirm") require.Error(t, err, stdOut, stdErr) - require.Contains(t, stdErr, "package is signed but no verification material was provided") + require.Contains(t, stdErr, "package was signed with a key; provide --key to verify") /* Test operations during package deploy */ // Test that we can deploy the package with the public key