Skip to content

Verify staged metadata.json authenticity with a cosign signature#340

Merged
cert-manager-prow[bot] merged 4 commits into
cert-manager:masterfrom
FelixPhipps:verify-signed-metadata
Jul 24, 2026
Merged

Verify staged metadata.json authenticity with a cosign signature#340
cert-manager-prow[bot] merged 4 commits into
cert-manager:masterfrom
FelixPhipps:verify-signed-metadata

Conversation

@FelixPhipps

@FelixPhipps FelixPhipps commented Jul 16, 2026

Copy link
Copy Markdown
Member

Description

Problem

The cmrel gcb publish command fetches a staged release from GCS and, before signing it with the production KMS key and pushing to public registries, decides what to publish based entirely on metadata.json — a file that lists every artifact and its checksum. That file is read from the same staging bucket prefix that the artifacts are written to, and the bucket is writable by more than just the pipeline (the Cloud Build SA plus several maintainer accounts). The current code trusts it with no proof of provenance:

  • Self-referential integrity gate: each artifact is accepted when sha256(body) == metadata.SHA256, but both sides come from the same unsigned file, so a self-consistent {metadata.json, *.tar.gz} set passes (CWE-345).
  • Validation checks shape, not origin: ValidateUnpackedRelease only asserts semver formatting and tag-string equality — no signature, commit-ref or provenance check.
  • Credentialed sink trusts it fully: whatever survives is docker loaded, retagged as quay.io/jetstack/cert-manager-*, pushed, and cosign-signed with the production KMS key.

Solution

Sign metadata.json at stage time with the release KMS key — which bucket writers do not hold — and verify that detached signature at publish time, before anything is unpacked. Signing and verification are done with cosign (sign-blob / verify-blob --key gcpkms://…), reusing the same tool and key already used to sign container images and Helm charts — one signing model to maintain and rotate, and cosign derives the digest algorithm from the key itself. pkg/sign/cosign gains SignBlob/VerifyBlob; a new cmrel sign metadata writes metadata.json.sig; and gcb publish verifies it against the KMS public key (fetched from KMS, so overwriting the signature can't forge validity).

A --require-signed-metadata flag fails closed, defaulting off during roll-out (a present signature is always verified, an invalid one always fails). Legitimate releases behave unchanged.

Note: this only takes effect once the cert-manager staging job (make upload-release) installs cosign and calls cmrel sign metadata to produce metadata.json.sig, on all supported branches (master, release-1.21, release-1.20). Those are separate follow-up PRs; the flag is then flipped to true.

Test plan

  • TestSignBlobArgs / TestVerifyBlobArgs — the cosign invocations are built correctly, including the gcpkms:// key format.
  • go build ./..., go vet ./..., and gofmt clean.
  • cmrel sign metadata --help confirms the subcommand is wired; verification is a no-op on today's unsigned releases (flag defaults off).
  • Existing pkg/release and cmd/cmrel/cmd tests pass unchanged.
  • Pre-existing TestValidate_Semver failure in pkg/release/validation is unrelated (fails identically on a clean tree).

release-note

cmrel: publish can now verify a cosign signature over the staged metadata.json (`--require-signed-metadata`); `cmrel sign metadata` produces the signature at stage time.

metadata.json is the root of trust for the publish step - it lists every
release artifact and its checksum - yet it is read from the same staging
bucket prefix that the artifacts are written to. On its own it carries no
proof of provenance, so a principal able to write to the bucket could stage
a self-consistent {metadata.json, artifacts} set that publish would unpack,
retag, push and sign with the production KMS key (CWE-345).

Bind a published release to metadata genuinely produced by the staging
pipeline by signing metadata.json with the release KMS key - which bucket
writers do not hold - and verifying that signature at publish time:

- pkg/sign: add SignMetadata/VerifyMetadata built on the existing kmssigner
  (RSA PKCS#1 v1.5 over SHA-512, matching the release signing key). The
  public key is fetched from KMS, so an actor who overwrites metadata.json
  and its signature still cannot forge a signature that verifies.
- cmrel sign metadata: sign a metadata.json on disk, writing a detached,
  base64-encoded metadata.json.sig alongside it. Intended to be run when
  staging a release, before upload.
- release.Staged: retain the exact metadata.json bytes and best-effort load
  metadata.json.sig, exposed via MetadataBytes()/MetadataSignature().
- gcb publish: verify the signature before unpacking anything. A new
  --require-signed-metadata flag (plumbed via _REQUIRE_SIGNED_METADATA)
  fails closed when a signature is missing; while it is false during
  roll-out, a signature that is present is always verified and an invalid
  one always fails.

Verification is off by default so this is a no-op on today's unsigned
releases; it becomes enforced once the cert-manager staging job signs
metadata on all supported branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Felix Phipps <fphipps@paloaltonetworks.com>
@cert-manager-prow cert-manager-prow Bot added dco-signoff: yes Indicates that all commits in the pull request have the valid DCO sign-off message. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Jul 16, 2026
…d KMS RSA

Replace the bespoke RSA PKCS#1 signing/verification of metadata.json with
cosign sign-blob / verify-blob, keyed off the existing release KMS key. This
reuses the same tool and key already used to sign container images and Helm
charts, so there is one signing model to reason about and rotate, and much
less code to maintain. cosign also derives the digest algorithm from the KMS
key itself, removing the previous hard-coded SHA-512 assumption.

- pkg/sign/cosign: add SignBlob/VerifyBlob wrapping cosign sign-blob and
  verify-blob with --key gcpkms://..., plus unit tests for the argument
  construction (including the cosign key format).
- cmrel sign metadata: shell out to cosign.SignBlob (new --cosign-path flag);
  cosign writes metadata.json.sig directly, dropping the in-process crypto and
  base64 handling.
- gcb publish: verifyStagedMetadata writes the staged metadata and signature to
  a temp dir and runs cosign.VerifyBlob before unpacking anything. The
  --require-signed-metadata roll-out behaviour is unchanged.
- Remove pkg/sign/metadata.go and its test.

Verification remains off by default (--require-signed-metadata=false), so this
is a no-op on today's unsigned releases and becomes enforced once the
cert-manager staging job signs metadata on all supported branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Felix Phipps <fphipps@paloaltonetworks.com>
@FelixPhipps FelixPhipps changed the title Verify staged metadata.json authenticity with a KMS signature Verify staged metadata.json authenticity with a cosign signature Jul 22, 2026

@SgtCoDFish SgtCoDFish left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we can maybe remove the signing command and save a bit of code - what do you think?

Comment thread cmd/cmrel/cmd/gcb_publish.go Outdated
Comment thread cmd/cmrel/cmd/sign_metadata.go Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I'm not sure if we need a sign command? The cert-manager release process is defined in a makefile and cosign is available.

Could we simply call cosign directly in the makefile? What's the justification for adding a signing command to cmrel?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, now calls the existing cosign tool as oppose to adding a new one.

…tion

Address review feedback:

- Remove the 'cmrel sign metadata' subcommand and cosign.SignBlob. cosign is
  already available as a tool in the cert-manager release Makefile, so the
  staging step can call 'cosign sign-blob' directly; a cmrel wrapper added
  surface for no real benefit. Publishing still verifies via cosign.VerifyBlob.
- Split the publish-side check into doVerifyStagedMetadata (does the work,
  returns an error on any failure) and verifyStagedMetadata (applies the
  --require-signed-metadata policy: tolerate-with-warning when unset, fail
  when set). This removes the per-branch RequireSignedMetadata checks that
  made the original hard to follow. As a side effect, a present-but-invalid
  signature is now also tolerated while the flag is unset, which is the
  intended roll-out behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Felix Phipps <fphipps@paloaltonetworks.com>

@SgtCoDFish SgtCoDFish left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you'll think "oh not another round of suggestions!" but I promise there's something valuable here! By slightly changing how we write this new code we can really evolve how defensively we code this.

Comment thread pkg/release/staged.go Outdated
Comment on lines +77 to +78
metadataBytes: metaBytes,
metadataSignature: metaSignature,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: there's an interesting design decision here - I think it's worth digging into it and thinking about it a sec.

We're storing the signature and the metadata in the staged release, so it can be used later. We certainly need to store the metadata for later.

As written though, any future refactor that touches meta (or the newly added metadataBytes) must always remember to ensure that the signature is validated before using meta. It's a really common cryptographic mistake to just forget to verify the signature (usually because of a refactor which confuses the control flow). An alternative version of that is that unverified data is allowed to be used before the signature is validated (which happens comically regularly in GPG and is one of the reasons GPG can be... challenging).

There's a more defensive way of designing this:

  1. Validate the signature here, at the earliest possible opportunity
  2. Fail early here if signature validation fails (still allow RequireSignedMetadata to be false)
  3. Don't store the signature or the metadataBytes in Staged

If we do that then all constructed Staged structs are either:

  1. Signature verified
  2. Signature not verified but explicitly skipped by the user (for the initial rollout)

Further, that means that none of the code which uses a Staged struct has to even think about signatures. Either you have an initialised struct and it's safe to use or else this function returned an error and the whole thing stops either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this design a lot better and I've implemented it in the most recent commit. Staged no longer stores metadataBytes or metadataSignature, and the accessors are gone. NewStagedRelease now takes a release.MetadataVerifier and runs it at the earliest opportunity — right after loading metadata.json, failing early if it doesn't verify. GetRelease forwards the verifier; ListReleases passes nil (listing is informational and must not require KMS/cosign).

The result is that every constructed Staged is either signature-verified or had verification explicitly skipped by the caller, and nothing downstream has to think about signatures.

Comment thread cmd/cmrel/cmd/gcb_publish.go Outdated
Comment on lines +104 to +106
// RequireSignedMetadata, if true, causes publishing to fail unless the
// staged metadata.json is accompanied by a valid signature (metadata.json.sig).
RequireSignedMetadata bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Default value for bool is false - which means the default value here is unsafe because it allows signature validation to fail. It's a little better design-wise to default to the safe value.

Concretely: If we changed this to AllowInvalidMetadataSignature bool (or something) then the default value is safer (i.e. missing / invalid signatures are not allowed). It means we have to be sure we're explicitly opting in to the flag everywhere (which is fine for the initial rollout - because nothing will be signed)

(this is a nitpick because it's highly unlikely this will ever cause a problem - but it's a good defensive programming practice)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, inverted it. It's now AllowInvalidMetadataSignature, defaulting to false — and verification is enforced unless explicitly opted out of.

… default

Address review feedback on the metadata signature verification design:

- Verify the metadata signature as the release is loaded, and stop storing
  the raw metadata bytes or signature on Staged. NewStagedRelease now takes a
  release.MetadataVerifier which is run before the Staged is returned;
  GetRelease forwards it and ListReleases passes nil (listing is
  informational and must not require KMS/cosign). This means every Staged
  handed to the rest of the program has either had its metadata authenticated
  or had verification explicitly skipped by the caller, so no downstream code
  can accidentally use unverified metadata - the unverified bytes simply are
  not retained.

- Invert the roll-out flag so the safe behaviour is the zero value. The
  gcb publish / publish flag is now --allow-invalid-metadata-signature
  (substitution _ALLOW_INVALID_METADATA_SIGNATURE), defaulting to false, so
  signature verification is enforced unless explicitly opted out of. A
  forgotten flag now fails closed instead of publishing unverified content.

During roll-out - before staging signs metadata.json on all supported
cert-manager branches - publishing an unsigned release requires explicitly
passing --allow-invalid-metadata-signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Felix Phipps <fphipps@paloaltonetworks.com>
@maelvls

maelvls commented Jul 24, 2026

Copy link
Copy Markdown
Member

Seems like it's good to go as Ash already reviewed.

/lgtm
/approve

@cert-manager-prow cert-manager-prow Bot added the lgtm Indicates that a PR is ready to be merged. label Jul 24, 2026
@cert-manager-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: maelvls

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@cert-manager-prow cert-manager-prow Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Jul 24, 2026
@cert-manager-prow
cert-manager-prow Bot merged commit 39272a9 into cert-manager:master Jul 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. dco-signoff: yes Indicates that all commits in the pull request have the valid DCO sign-off message. lgtm Indicates that a PR is ready to be merged. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants