Cosign: The Manual Way

Eddie Zaneski

When I first used Cosign, the software artifact signing CLI from the Sigstore project, I was amazed at how painless signing and verifying could be.

For example, in the three commands below we create a public/private key pair, sign the text file, upload it to the Rekor transparency log, and verify the signature of the message.

# create public/private keys
$ cosign generate-key-pair
Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to

# sign an artifact and output the signature
$ cosign sign-blob --key cosign.key --output-signature sig message.txt
Using payload from: message.txt
Enter password for private key:
tlog entry created with index: 2014997
Signature wrote in the file sig

# verify the signature
$ cosign verify-blob --key --signature sig message.txt
tlog entry verified with uuid: 7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e index: 7155742
Verified OK

These quick commands show how easily Sigstore can be integrated into software security practices for any developer. I am the kind of person who always wants to know what’s going on under the hood. In this tutorial, I will walk you through doing Cosign the manual way so you too can understand the ins and outs of Cosign.


If you want to follow along you’ll need the following installed from your package manager of choice. I’ve noted the version used in this post but different minor versions should be fine. However, keep in mind that OpenSSL can drastically vary per system.

The Blob

A blob is an arbitrary collection of raw data like a picture or the executable binary that your source code produces. Cosign is capable of signing and verifying blobs.

In our case, we’ll be signing a spooky message. Let’s write that message to a file.

$ echo 'Beware The Blob!' > message.txt

We’ll be using Cosign to sign this .txt file blob.


Before we can sign our message we need to generate a key pair. A key pair is a set of two keys, one private and one public, that can both sign/verify and encrypt/decrypt data. The private key is generated by some algorithm and the public key is derived from the private.

There are a handful of different algorithms in use today, but the most common are RSA, ECDSA, and Ed25519. I fell deep down the rabbit hole learning about the differences between these algorithms, potential NSA backdoors and which versions of OpenSSL support a given algorithm. To keep things simple, we’ll use RSA but I encourage you to chase the rabbit on your own.

The RSA algorithm in use today is defined by the Public Key Cryptography Standards #1 (PKCS1) specification. Let’s generate a 4096 bit RSA private key with OpenSSL.

$ openssl genrsa -out key.pem 4096
Generating RSA private key, 4096 bit long modulus (2 primes)
e is 65537 (0x010001)

And from that private key we can output the public key.

$ openssl rsa -in key.pem -pubout -out pub.pem
writing RSA key
$ cat key.pem

$ cat pub.pem
cat pub.pem

We now have our keys but this is still a bit of magic. What is a PEM and what is this syntax? To answer that, let’s look at doing the same thing we just did in Go (omitting errors for brevity).

// main.go
package main

import (

func main() {
	key, _ := rsa.GenerateKey(rand.Reader, 4096)
	b := x509.MarshalPKCS1PrivateKey(key)
	priv := pem.EncodeToMemory(&pem.Block{
		Bytes: b,
	os.WriteFile("key.pem", priv, 0600)

	b, _ = x509.MarshalPKIXPublicKey(key.Public())
	pub := pem.EncodeToMemory(&pem.Block{
		Type:  "PUBLIC KEY",
		Bytes: b,
	os.WriteFile("pub.pem", pub, 0600)

Go’s crypto library is a joy to work with and the implementation for rsa.GenerateKey is worth peeking at. After calling it to generate our private key, we have a bunch of mathy bits that we need to represent somehow — enter Abstract Syntax Notation number One (ASN.1) and Distinguished Encoding Rules (DER).

ASN.1 is the syntax we express our key in (think a .proto file) and DER is how we encode it (think Protobuf wire format). For a more detailed explanation, Let’s Encrypt has a great article. After calling x509.MarshalPKCS1PrivateKey our private key is an ASN.1 DER encoded PKCS1 byte array 😅.

We still have one last step, however. Byte arrays aren’t human readable and are difficult to copypasta. As we often do when transmitting binary data we need to Base64 encode our bytes. Slapping on a header (-----BEGIN RSA PRIVATE KEY------) and footer (-----END RSA PRIVATE KEY-----) to help parsers identify our content finally leaves us with our Privacy-Enhanced Mail (PEM) file. PEM is a standard container for certificates and keys and was created to send binary data over email without messing with their contents.

Since PKCS1 is specific to RSA our public key is serialized to Public Key Infrastructure X.509 (PKIX) which is a generic public key representation that includes information like the key algorithm. X.509 is the standard that defines the format for public key certificates like those used in your web browser. You can see above that the header is the generic -----BEGIN PUBLIC KEY-----.

Time to Sign

With our keys created, we are ready to use them to sign our message. We could sign the file in its entirety, but that would require the full file when verifying the signature. This is fine for small text files but when signing and uploading 3 gigabyte containers, it’s much more efficient to hash the payload and sign the digest. That way all that’s needed to verify a signature is the public key and the hash value. SHA-256 is most commonly used today for computing a message digest.

$ openssl dgst -sha256 -sign key.pem -out message.txt.sig message.txt

We can confirm our signing was successful by verifying the signature with the public key.

openssl dgst -sha256 -verify pub.pem -signature message.txt.sig message.txt
Verified OK

Get Transparent

Now that we have our signature, we can upload everything to the Rekor transparency log so others can find and verify it. Rekor supports a handful of different distinct types including Java JARs and RPM packages. The basic type is known as a Rekord but since we signed the hash of our file we’ll use a Hashed Rekord.

Most of the fields in Rekor’s types require Base64 encoding, so let’s store these values in environment variables to make writing the payload easier.

$ SIGSTORE_SIG_CONTENT=$(cat message.txt.sig | base64 | tr -d '\n')
$ SIGSTORE_PUBLIC_KEY=$(cat pub.pem | base64 | tr -d '\n')
$ SIGSTORE_HASH_CONTENT=$(shasum -a 256 message.txt | cut -d " " -f 1)

Next, we’ll write out the payload to a file to make it easier to inspect and use in a request.

$ cat <<EOF > hashedrekord.json
  "apiVersion": "0.0.1",
  "kind": "hashedrekord",
  "spec": {
    "data": {
      "hash": {
        "algorithm": "sha256",
        "value": "$SIGSTORE_HASH_CONTENT"
    "signature": {
      "content": "$SIGSTORE_SIG_CONTENT",
      "publicKey": {
        "content": "$SIGSTORE_PUBLIC_KEY"

Do a quick sanity check that our payload was created successfully.

$ cat hashedrekord.json
  "apiVersion": "0.0.1",
  "kind": "hashedrekord",
  "spec": {
    "data": {
      "hash": {
        "algorithm": "sha256",
        "value": "d8d321

We’re now ready to send our data off to Rekor. Let’s save the response to a file so we can poke around.

$ curl -X POST -H "Content-Type: application/json" --data-binary @hashedrekord.json > response.json

The top-level key of the response will be the database shard ID (16 characters) + entry UUID (64 characters) in the transparency log.

# top level key
$ jq -r 'keys[0]' response.json

# shard id
$ jq -r 'keys[0]' response.json | cut -c -16

# entry uuid
$ jq -r 'keys[0]' response.json | cut -c 17-

The entry UUID is actually the hash of the Merkle Tree leaf node for our entry. I won’t attempt to explain Merkle Trees in depth, but the basic idea is that inclusion of a node can be cryptographically verified all the way up to the root hash of the tree. RFC 6962 explains how this works and we’ll step through this verification in a bit. For now, let’s see where this hash comes from.

The RFC states that the hash of a leaf node is SHA-256(0x00 || d(n)). That is, the SHA 256 sum of the hex byte 0x00 concatenated to the contents of the entry, which in our case is the Hashed Rekord. We can do this in bash with process substitution.

$ shasum -a 256 <(cat <(printf '\x00') <(jq -rcj '.' hashedrekord.json )) | cut -d ' ' -f 1

Now let’s retrieve the entry from Rekor using this ID and save it to a file. If you inspect the contents, you’ll notice a handful of new fields.

$ curl > entry.json

First, let’s check that our Hashed Rekord is in the body.

$ diff <(jq -rc '.[].body' entry.json | base64 -d) <(jq -rcj '.' hashedrekord.json)

The body matches, which confirms that our entry was created, but how would the recipient of our message go about verifying and proving that for themselves? That’s where our next step comes in.

Trust but Verify

Well, there are a few important things they must check:

  • That the message digest matches the signature for the public key inside the Hashed Rekord. (We’ll leave this as an exercise for you, dear reader: you can reverse the steps from earlier.)
  • That we were in possession of the key when the message was signed. (You could check AWS/GCP KMS access logs)
  • That the entry is indeed included in the transparency log. (RFC 6962)

The next thing that we can verify in the entry is the Signed Entry Timestamp. I’ll leave this explanation to our friend Hayden Blauzvern.

As a transparency log, Rekor provides cryptographic proofs of inclusion in a log. Fetching an inclusion proof requires querying the log. The log returns a checkpoint (signed tree head) as a commitment to the current state of the log and the inclusion proof.

Requiring an online lookup for every entry that you’re verifying could cause a lot of increased latency in a verifier, and requires that the log have very high availability. Ideally, Rekor could provide an inclusion proof that could be verified offline – Rekor does this with a “signed entry timestamp” (SET).

An SET is a structure signed with the same private key that signs Rekor’s checkpoints. It is a “promise” of inclusion. It does not contain cryptographic proof, but since it is signed by the log, the log is committing to including the entry. A verifier that trusts Rekor can verify the SET without needing to do an online lookup. Asynchronously, for additional assurances, a log monitor can verify that an entry is truly present in the log for each SET a verifier views.

We can start by fetching Rekor’s current public key.

$ curl >

Next we can pull the SET out of the entry into its own file.

jq -r '.[].verification.signedEntryTimestamp' entry.json | base64 -d > set.sig

The attestation and verification fields in the entry are not included in what is signed by the timestamping authority, so let’s remove them.

jq -cj '.[] | del(.attestation, .verification)' entry.json > set.json

Finally, we can verify the SET.

$ openssl dgst -sha256 -verify -signature set.sig set.json
Verified OK

The last thing we need to verify is that our entry was actually included in the Merkle tree. As mentioned earlier, this is defined in RFC 6962. RFC 9162 will eventually replace 6962 and the pseudocode is easier to follow (read through it first).

What follows is a bash implementation of this algorithm that I am equally proud of and upset by. It builds on everything we’ve covered so far with the addition of the xxd tool. xxd is used to convert between binary and hex and we use it to build up the binary representation of our tree nodes from the hashes in our entry. These hashes should eventually compute to the rootHash.

#!/usr/bin/env bash

set -euo pipefail


entry=$(cat entry.json)
mapfile -t hashes < <(jq -rc '.[].verification.inclusionProof.hashes | .[]' <<< "$entry")
rootHash=$(jq -r '.[].verification.inclusionProof.rootHash' <<< "$entry")
startHash=$(shasum -a 256 <(cat <(printf '\x00') <(jq -r '.[].body' <<< "$entry" | base64 -d)) | cut -d ' ' -f 1)
logIndex=$(jq -r '.[].verification.inclusionProof.logIndex' <<< "$entry")
treeSize=$(jq -r '.[].verification.inclusionProof.treeSize' <<< "$entry")

if [[ $logIndex -ge $treeSize ]]; then
  echo "verification failed! log index larger than tree size"
  exit 1

echo -e "0x00 || leaf\nstart: ${startHash}\n\n"
echo -e "want: ${rootHash}\n\n"

sn=$(( treeSize - 1 ))

for i in "${!hashes[@]}"
  if [[ $sn -eq 0 ]]; then
    echo "verification failed! tree is incomplete"
    exit 1

  lsb=$(( fn & 1 ))

  if [[ ($lsb -eq 1) || ($fn -eq $sn) ]]; then
    echo "0x01 || ${hashes[i]} || ${r}"
    r=$(shasum -a 256 <(cat <(printf '\x01') <(xxd -r -p <<< "${hashes[i]}") <(xxd -r -p <<< "${r}")) | cut -d ' ' -f 1)
    while [[ ($lsb -eq 0) || $fn -eq 0 ]]; do
      fn=$(( fn >> 1 ))
      sn=$(( sn >> 1))
      lsb=$(( fn & 1 ))
    echo "0x01 || ${r} || ${hashes[i]}"
    r=$(shasum -a 256 <(cat <(printf '\x01') <(xxd -r -p <<< "${r}") <(xxd -r -p <<< "${hashes[i]}")) | cut -d ' ' -f 1)
  fn=$(( fn >> 1 ))
  sn=$(( sn >> 1))
  echo -e "${r}\n\n"

if [[ "${r}" == "${rootHash}" ]]; then
  echo "verification successful! got: ${r} want: ${rootHash}"
  exit 0
  echo "verification failed! got: ${r} want: ${rootHash}"
  exit 1

Running this monstrosity, we can see that verification was successful!

$ ./
0x00 || leaf
start: 7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e

want: b054e63c475d8751b14b51a54255918e4ae0aa26e0ee60c9c7ee3e333396c4ad

0x01 || 2fb6c414fe3710a0ca820e20a352d827e920ef94de1a62cf032f0b508583d343 || 7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e

0x01 || af8df402b3a2f95a2e11e693d0401237c23ab1ef29844ff5b122ef7b868eb802 || 16e4d477cd081846cd410daf07299d17056dc5b31f77c40a716e8520a0457b1c

0x01 || 54b4fb9c6af94d6c8c9361176de175cb26334f26cf0c2238c8dda23ca256f7b1 || 6e76df3263704eccc1c047bbc1704bbf8071a817a0c5626a2308da7d9bf3cae9

0x01 || dff1cbc7728565c69328b8618b14fe7033baa5e8936098ec8933009c3988104e || 7e12ab8d4338713ca3a427db4eb41ecdbe3e6ae15fd490716409d8c89c63ae1d

0x01 || 94cc41349e90789f7f80dce5df0339373749400539d41c8a3ecca485e3c92603 || 6ad77f4dcac7a5d47d3087283129429d50cd2d9a4410f5d4900afd78e33ad40a

0x01 || a50a2b2e4a506dd075f59dab6db168164eb52e437756f53162f04be6ce0b5c3b || 4fdd278ae02e4cbae8dbc0650e4d3282e208ffec9603c68f73bcafe328488ac7

0x01 || 65cfed18ce41b642653912e86402b45d5df0109de29edbf0cb20c62f3444fdc0 || 52f10e8d704883a11ed3f157d079afffd814f563d5c013d5752ea81744aac4b5

0x01 || a0441f106e6bb9ec4acc5f3126beea2ae60b721648c3c7ba741368458cef89c6 || 749a1795a60841356d1928b78af94814b127b7fea9aede093eb39b410e166f27

0x01 || 5d0ebe394bc76ddf46868b03466d3042b4efcb70760f997731ee6758c272208f || df2f19f10ed0def555c68cda80b4fdd8f535ba929c1cfc36d9c789732d20e305

0x01 || 9138f73ed31973bab8e005465ee04bec1cfb2a56e0f6eb9f316d964127c9877e || 93663301e472c817961f75499d26844b246665d275538e5a24e16b065cda7afe

0x01 || c2ed30483aafe58267a6740c2fb76408eb6366ceb48c9f0509c0f2c04b101f05 || 874589aee5388025c078a59e1f6e66ac65d185cdc766620860ba0680cfbd26bc

0x01 || b593f43b0ca6048ca4e1564817b0381f25f66d6c6b205673d6e6bc1c582a02bd || 0926480781b8d7bc2790d7e63972846bf98b79b47a1968a47f9c0992f319da44

0x01 || b8f5ef19b43c82a6deee74a4b36c41a4b7f030d46f9862913ae5ab74177d7a34 || 1af9b80f354a6c824fe9893f7278ce192dea39f4cb800707681947408b9f8c60

0x01 || 0b4e9c3dce66ce4f393b3b3ae5f141a0d20902538d5fc99bd030348672b69b81 || b76bf4a5ee7dbf09372243c01c86d620fc3581e8c4b5659ce549b397ec9608bc

0x01 || 9a991dc567e6dd4fa21d6235df691252ca81eab5e1301c849c8aa2151363e6a7 || 31fcf995aaadf0cc7ce249b3ae06fb73653648c6279dd8401a75e59538a85718

0x01 || 732af72ebcc0c8a16dbdba657c7b755d2097691d08882f308dbc5b133372c170 || 36a05fd84e2e4f497a41a3e6e633b59a49873d731e1d4e2e5c91ecb5ece28091

0x01 || 2747468d0ed5e5b1138bba7b7968367a9842437d9004b3166391f115cb867d1e || 5219998b60322aa7e1aa3c02cf15bedc67d89876dfc46f1b89937c8e496aa947

verification successful! got: b054e63c475d8751b14b51a54255918e4ae0aa26e0ee60c9c7ee3e333396c4ad want: b054e63c475d8751b14b51a54255918e4ae0aa26e0ee60c9c7ee3e333396c4ad

This is the same flow that the Rekor CLI will step through.

$ rekor-cli verify --uuid 24296fb24b8ad77a7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e

Current Root Hash: f0e0de7e6b03385bc086c46703b7a2abbbd16ae10fc28b3f480125b1898536fb
Entry Hash: 24296fb24b8ad77a7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e
Entry Index: 7155742
Current Tree Size: 2995354
Checkpoint: - 2605736670972794746
Timestamp: 1668548431487913062

— wNI9ajBEAiB/Lcxmn82//9QIwqVPbVSgzEAfACmAnZNLD9RuIH9QiAIgLToW3Bd8Y26Wwz3JuuZBsC1/IhUExSbu1NET/nzoajc=

Inclusion Proof:
SHA256(0x01 | 24296fb24b8ad77a7e53fd5d089af142b7909598d214e13ca76001cc575fddaad3210adbee86363e | 2fb6c414fe3710a0ca820e20a352d827e920ef94de1a62cf032f0b508583d343) =

If you have any question you can reach me @eddiezane!

Stay spooky…

Special thanks to Appu Goundan and Hayden Blauzvern.

Last updated: 2023-03-29 08:49