Post

Verifying Cosign signatures offline

Verifying Cosign signatures offline

Let’s walk through how to verify a container image (or any other artifact) signed with Cosign. While open-source software is built in the public, on the internet, it’s still possible to verify signatures in a completely disconnected environment.

A tiny bit about Cosign

Cosign is the part of Sigstore . It cryptographically signs software artifacts to provide verification of itself and the identity of the signer. It also stores evidence of the signature in a transparency log (Rekor ), allowing tamper-evident verification of those signatures at any point in time later on.

fulcio

Cosign isn’t opinionated on what type keys are used or which artifacts are signed1. If the project you need is using a long-lived key, it’s simple to verify any artifact signed with it by using the public half. However, key management adds another threat that can be removed with keyless signing with OIDC.

In short, a project can get a very-short-lived certificate from Fulcio to sign an artifact using their identity provider. Which certificate is valid at what times is all stored in that transparency log too, meaning we need to be able to verify it was valid when signed.

While it’s possible to self-host this infrastructure entirely, that only solves for software built or signed in-house. For open-source software, all of this infrastructure is available for free on the internet using the public good instance . But trust on the internet and trust in your disconnected environment are not the same.

How do we verify public signatures offline, without access to logs on the internet?

Low-side set up

We’ll need a few things on our server to gather up all the things to ship over. This server needs a few things:

  • Cosign installed and ready to go2
  • the connectivity needed to start a promotion into a disconnected environment (or a DVD burner)

Install Cosign

The install directions vary by platform, but you can pull the latest release from GitHub too. Once you’re ready to go, verify it’s installed as shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cosign version
______   ______        _______. __    _______ .__   __.
/      | /  __  \      /       ||  |  /  _____||  \ |  |
|  ,----'|  |  |  |    |   (----`|  | |  |  __  |   \|  |
|  |     |  |  |  |     \   \    |  | |  | |_ | |  . `  |
|  `----.|  `--'  | .----)   |   |  | |  |__| | |  |\   |
\______| \______/  |_______/    |__|  \______| |__| \__|
cosign: A tool for Container Signing, Verification and Storage in an OCI registry.

GitVersion:    v3.0.2
GitCommit:     84449696f0658a5ef5f2abba87fdd3f8b17ca1be
GitTreeState:  "clean"
BuildDate:     2025-10-10T18:17:56Z
GoVersion:     go1.25.2
Compiler:      gc
Platform:      darwin/arm64

Download the trust root

Now initialize Cosign and download a copy of the trust root for the public good instance of Sigstore. The command is silent. By default, it’ll write the files we need to ~/.sigstore.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cosign initialize
$ tree ~/.sigstore
/Users/natalie/.sigstore
└── root
    ├── remote.json
    ├── tuf-repo-cdn.sigstore.dev
    │   ├── root.json
    │   ├── snapshot.json
    │   ├── targets
    │   │   └── trusted_root.json
    │   ├── targets.json
    │   └── timestamp.json
    └── tuf-repo-cdn.sigstore.dev.json

4 directories, 7 files

Chainguard specifics

If you’re using the free images, this is super easy and I’ll call out how to do it. However, my folks want to do this with their paid, private images too. These use a different signature authority, allowing for teams to customize their images and for those custom images to also be signed and verified.

Get the identity string for your organization by logging into the Chainguard console , click Settings on the bottom of the left-hand bar and then Users. You want the identities of the apko_builder and catalog_syncer services for your organization. One is shown below.

apko-id-light apko-id-dark where to find what you need to verify images from the Chainguard console

These are the same identities as shown in the catalog’s directions for verification if you click on the “Provenance” tab on any image, but without having been filled in magically for you in your browser.

Download a signed image

Make a directory to save our images to hurl over the wall promote high-side, then save them locally. We’ll do one together.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cosign save cgr.dev/some-natalie.dev/python:3.11 --dir ~/xfer/promote
$ tree ~/xfer/promote
/Users/natalie/xfer/promote
├── blobs
│   └── sha256
│       ├── 001a03ba8eee511cc4902fedf1c52d83b27b173c18cfb7156c80d20b6d573b9f
│       ├── 06108b4ced367948b287accee18a28b42e9d4851653bbaa4a485a095025cd0b8
│       ├── 1e65ba314659c42899b1a02caeeb437b51f0ba04093bd16b8e68d67dd3d52d91
│       ├── ... many more SHA sums here ... 🪄 magically truncated 🪄
│       └── fd477935ae5f8288dc84ba7b36caa366039aeefdfdbcf1ba9b2344840b375884
├── index.json
└── oci-layout

3 directories, 37 files

Bundle things for disconnected environment

Send through the following items:

  • the complete contents of ~/.sigstore, the local copy of the trust root
  • the complete contents of ~/xfer/promote, the image we’re moving over

Additionally, the high-side machine that will verify it must also have cosign installed and available. It is an open-source project that you can compile yourself, or download the static binary, or use your distribution’s copy of if it is packaged.

Verification

From here, use cosign verify with the appropriate arguments2 to validate that the image is signed and hasn’t changed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# for a paid, private image (swap in your identities)
cosign verify \
  --certificate-identity-regexp "https://issuer.enforce.dev/(a50359a5d99a0d775184e7d5690f65d1db24a192/81b0fe09f44bad4e|a50359a5d99a0d775184e7d5690f65d1db24a192/3b61296a05ac7466)" \
  --certificate-oidc-issuer "https://issuer.enforce.dev" \
  --offline=true \
  --new-bundle-format=false \
  --trusted-root ~/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json \
  --local-image ~/xfer/promote

# for a free, public image
cosign verify \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  --certificate-identity="https://github.com/chainguard-images/images/.github/workflows/release.yaml@refs/heads/main" \
  --offline=true \
  --new-bundle-format=false \
  --trusted-root ~/.sigstore/root/tuf-repo-cdn.sigstore.dev/targets/trusted_root.json \
  --local-image ~/xfer/promote

If you have the unmodified image and trust root, you should see something like this in return:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Verification for /Users/natalie/xfer/promote --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority certificates

[
  {
    "critical": {
      "identity": {
        "docker-reference": "cgr.dev/a50359a5d99a0d775184e7d5690f65d1db24a192/74f1e73c2572fa13"
      },
      "image": {
        "docker-manifest-digest": "sha256:e724ec5cbe883446f7a4ef6b92851bb3690f44953dba1bdcaf5b5674e80e03a2"
      },
      "type": "cosign container image signature"
    },
    "optional": {
      "1.3.6.1.4.1.57264.1.1": "https://issuer.enforce.dev",
      "Bundle": {
        "SignedEntryTimestamp": "MEQCIC1JyMTSLijAsFIWlM0ztyFM8wCybpyr4GG2Zw9TUrAtAiAekvu5tFqtSOdpY7a0Ejt/xXnW3wBkRQLlwTJJ2Z+S9A==",
        "Payload": {
          "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJjMjY2ZDkxNWI5NjgyN2FkYzU1ZjU0NDJiOTRiMzkyYjdkZjhiNmYxNjJjMjE0MGM1MTVkZTJlMjQwZDJiZmNkIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUUNhbldnWitVMTJvaWU1aTdDVjUyd2NpNDlwTng2SWZhOHZQT3JOTDhoSnh3SWdiSWl1SnAwdHE1QnphTlByRzZLUHRaek5jeFY3YW1SeXZLTXNySFg3bWJrPSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVUkRla05EUVhCRFowRjNTVUpCWjBsVlkwUjZhSEEwYzBSRk1tcDRUbkYzTDFaS1lVeFRZM3BWSzJ4SmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFZlRTFVUVROTmFrbDZUVVJKZDFkb1kwNU5hbFY0VFZSQk0wMXFTVEJOUkVsM1YycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVU0ZEdoVFoydEpVbTByZVdGQ09EZHJVbWRMVEVkWVpVTm9lSGxSTjA5alFrMUtRMUFLV0ZGSWVWVnNVRFJUWkdkTmNYRnJWMHB4UVRscGVqZDNhME5NWW5OSVkxbHpkbmRPWjBoaGNWWkxaVFpOV1VSSWJHRlBRMEZoT0hkblowZHlUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZVWTJOaENtSjFOVmR1YVRWUlRUUkpSMVprVmpONlZXSmliMFpCZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDFsbldVUldVakJTUVZGSUwwSkdaM2RXYjFwVllVaFNNR05JVFRaTWVUbHdZek5PTVZwWVNYVmFWelZ0WWpOS2FscFROV3RhV0ZsMldWUlZkd3BOZWxVMVdWUldhMDlVYkdoTlIxRXpUbnBWZUU5RVVteE9NbEV4VG1wcmQxcHFXVEZhUkVacldXcEpNRmxVUlRWTmFUaDZXV3BaZUUxcWF6SlpWRUV4Q2xsWFRUTk9SRmt5VFVOblIwTnBjMGRCVVZGQ1p6YzRkMEZSUlVWSGJXZ3daRWhDZWs5cE9IWmhXRTU2WkZkV2VVeHRWblZhYlRsNVdUSlZkVnBIVmpJS1RVTnZSME5wYzBkQlVWRkNaemM0ZDBGUlowVklRWGRoWVVoU01HTklUVFpNZVRsd1l6Tk9NVnBZU1hWYVZ6VnRZak5LYWxwVE5XdGFXRmwzWjFsclJ3cERhWE5IUVZGUlFqRnVhME5DUVVsRlpYZFNOVUZJWTBGa1VVUmtVRlJDY1hoelkxSk5iVTFhU0doNVdscDZZME52YTNCbGRVNDBPSEptSzBocGJrdEJDa3g1Ym5WcVowRkJRVnB3WjJOVGJ6VkJRVUZGUVhkQ1IwMUZVVU5KUm1sTlFrSnBPSEJqYTJsMVdGRXdZV2RhV0RsWk5tNURSMnMyU0VGQ1V6RXdjbFFLTlRkNlVpdDVNamxCYVVGWVNFUXhUekJCU1Uxd1UyWXZlbEpLWmxaaWFHRkRjRTV6UVhobFkzVjVURWRYVVc1T2RrWnZVM1Y2UVV0Q1oyZHhhR3RxVHdwUVVWRkVRWGRPY0VGRVFtMUJha1ZCSzJSWVR6Z3lVMHR1ZUVaSldsaFpSVlpuZG1KclRXTXlNMWxzYUVoRFVVczJWMmszUkRGNE0zaDVTekpVU0ZsRkNucFJRMG93UjJsc1praGtlRWhWVUN0QmFrVkJkVzU0ZW1ZMWJtWmtRa2xzTDBwMlJETlFXV3d4VUdOcWQyTkJiVEJyWWt3NUwydDNNVk51VW5SWWVUY0tTemN6VVdkSGIyeDNWR2N2TjBoQ2NuaEZNV0VLTFMwdExTMUZUa1FnUTBWU1ZFbEdTVU5CVkVVdExTMHRMUW89In19fX0=",
          "integratedTime": 1762554625,
          "logIndex": 683767150,
          "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"
        }
      },
      "Issuer": "https://issuer.enforce.dev",
      "Subject": "https://issuer.enforce.dev/a50359a5d99a0d775184e7d5690f65d1db24a192/3b61296a05ac7466"
    }
  }
]

There’s a lot going on in the information printed above, but in short, it proves a “chain of custody” between the artifact downloaded on the internet and what’s now in your disconnected environment. It says that

  • ✅ The image hasn’t been tampered with since it was signed (the image digests match)
  • ✅ It was signed by a verified entity using Chainguard’s platform (who signed it, which should be Chainguard for a private image)
  • ✅ The signing event is publicly auditable via the transparency log
  • ✅ The certificate used for signing chains back to a trusted CA

All of this can be verified using the bundled information in an image and a copy of the public trusted root for Sigstore. None of it needs internet access to work.


Footnotes

  1. Cosign can sign basically anything, but 99/100 times anyone asks me about it, we’re signing and verifying signatures on container images. 

  2. Cosign v3 shipped in October 2025, but the pull request to support offline validation for the new protobuf specification hasn’t landed in a release yet. In the meantime, you’ll need to use --new-bundle-format=false for v3, but that flag doesn’t exist in v2 and isn’t necessary.  2

This post is licensed under CC BY-NC-SA 4.0 by the author.