Skip to content

Cosign Writeup

Nabendu M edited this page Oct 6, 2026 · 1 revision

Cosign Binary Signing — orch-ci reference & rpc-go-sec implementation

This document explains how Cosign signing works in orch-ci (the reference we borrowed from) and what we implemented in rpc-go-sec to sign our release binaries.


1. Background: what Cosign actually does

Cosign (part of the Sigstore project) signs software artifacts so consumers can prove who produced a binary and that it hasn't been tampered with.

There are two signing modes:

Mode Key material Where the trust comes from
Key-based A long-lived cosign.key / cosign.pub pair you store as a secret Whoever holds the public key
Keyless (what we use) No stored keys. A short-lived certificate is minted per-signing from an OIDC identity Fulcio (CA) + Rekor (transparency log)

Keyless signing is the modern default and what both orch-ci and our repo use. The flow:

sequenceDiagram
    participant CI as GitHub Actions Job
    participant OIDC as GitHub OIDC Provider
    participant Fulcio as Fulcio CA
    participant Rekor as Rekor Log
    participant Cosign

    CI->>OIDC: Request identity token (needs id-token: write)
    OIDC-->>CI: Short-lived OIDC JWT (identity = workflow file @ ref)
    CI->>Cosign: cosign sign-blob <artifact>
    Cosign->>Fulcio: Here's my OIDC token, give me a cert
    Fulcio-->>Cosign: Short-lived signing certificate (~10 min)
    Cosign->>Cosign: Sign artifact with ephemeral private key
    Cosign->>Rekor: Record signature in public transparency log
    Rekor-->>Cosign: Inclusion proof
    Cosign-->>CI: .sig + .pem (cert) + .bundle.json
Loading

Key consequence: the "identity" of the signature is the workflow file + git ref (e.g. .../release.yml@refs/heads/main), not a person. Verification checks that identity, not a stored public key. This is why id-token: write permission is mandatory — without it the job can't get the OIDC token and signing fails.


2. How orch-ci does it (the reference)

orch-ci wraps Cosign in a composite action so every repo in the org signs the same way. Two pieces:

2a. The composite action — .github/actions/security/cosign/action.yml

It accepts target (image or binary), the artifact path/ref, an environment, and a gh_token. For a binary it runs:

cosign sign-blob \
  --yes \
  --bundle cosign.bundle.json \
  --output-signature cosign.sig \
  --output-certificate cosign.cert \
  "$ARTIFACT"

…then verifies it with cosign verify-blob (against an open-edge-platform/* identity regex), and uploads the cosign.* files as a run artifact.

2b. The caller — post-merge.yml sign-binaries job

flowchart TD
    A[run-repo-pipelines<br/>builds binaries, outputs list] --> B{binaries<br/>non-empty?}
    B -- yes --> C[sign-binaries job<br/>matrix: one per binary]
    C --> C1[Checkout orch-ci<br/>to get the cosign action]
    C1 --> C2[Download built binary artifact]
    C2 --> C3[Resolve real path on disk]
    C3 --> C4[Run cosign composite action<br/>target: binary]
    C4 --> C5[Move cosign.* into folder]
    C5 --> C6[Upload signatures as artifact]
    C6 --> D[publish-signed-artifacts job]
    D --> D1[Download binaries + all cosign.* artifacts]
    D1 --> D2[Merge signatures next to binaries]
    D2 --> D3[Push signed package to ECR/S3]
Loading

Notable design points in orch-ci:

  • permissions: id-token: write + contents: read on the signing job (needed for keyless OIDC).
  • Matrix fan-out — one signing job per binary, so signatures are produced in parallel and re-joined later.
  • Signatures travel as workflow artifacts between the sign job and the publish job, then get merged back alongside the binaries before the final push.

3. What we implemented in rpc-go-sec

Our release model is different from orch-ci, so we adapted the pattern rather than copy it verbatim.

3a. Why we couldn't copy orch-ci directly

Aspect orch-ci rpc-go-sec
Build driver make pipelines in a big reusable workflow build.sh invoked by semantic-release
Release mechanism Push to ECR/S3 @semantic-release/github uploads assets to a GitHub Release
Binary discovery Dynamic matrix from job outputs Fixed set of 5 artifacts known to build.sh
Signing trigger Separate sign-binaries job Must run inside semantic-release's prepareCmd, since that's the only place the built binaries exist

Because semantic-release owns the build → release lifecycle, the cleanest insertion point is: build → sign → release, all inside the existing release job.

3b. The pieces we added / changed

flowchart LR
    subgraph release.yml [".github/workflows/release.yml (release job)"]
        P[permissions:<br/>+ id-token: write] --> I[Install Cosign<br/>cosign-installer v4.0.0 / v3.0.2]
        I --> SR[Semantic Release step]
    end

    subgraph semrel ["semantic-release (.releaserc.json)"]
        SR --> PC["prepareCmd:<br/>./build.sh VERSION<br/>&amp;&amp; ./sign.sh<br/>&amp;&amp; docker build ..."]
        PC --> GH["@semantic-release/github<br/>assets: binaries + *.sig + *.pem<br/>+ *.cosign.bundle.json"]
    end

    subgraph scripts [scripts]
        BUILD[build.sh<br/>produces 5 artifacts] --> SIGN[sign.sh<br/>cosign sign-blob per artifact]
    end

    PC -.calls.-> BUILD
    PC -.calls.-> SIGN
    GH --> REL[(GitHub Release<br/>binaries + signatures)]
Loading

1. sign.sh (new) — mirrors orch-ci's sign-blob invocation, looping over the five release artifacts that build.sh produces. Per artifact it emits:

  • <artifact>.sig — detached signature
  • <artifact>.pem — the short-lived Fulcio signing certificate
  • <artifact>.cosign.bundle.json — the bundle pairing sig+cert+Rekor proof for offline cosign verify-blob

2. .releaserc.json

  • prepareCmd now runs ./sign.sh right after ./build.sh (binaries must exist first) and before docker build.
  • Added *.sig, *.pem, *.cosign.bundle.json globs to the @semantic-release/github assets, so signatures attach to the GitHub Release next to the binaries.

3. release.yml

  • Added id-token: write to the job permissions (required for keyless).
  • Added an Install Cosign step (sigstore/cosign-installer v4.0.0, pinned to the same SHA and cosign-release: v3.0.2 as orch-ci) before Semantic Release, so cosign is on PATH when prepareCmd runs sign.sh.

4. .gitattributes — added sign.sh text eol=lf (matching build.sh) so the shebang isn't broken by CRLF on checkout.

3c. Runtime flow on a real release

sequenceDiagram
    participant GH as GitHub Actions (release job)
    participant SR as semantic-release
    participant B as build.sh
    participant S as sign.sh
    participant Fulcio
    participant Rel as GitHub Release

    GH->>GH: Install cosign, grant id-token: write
    GH->>SR: Run Semantic Release
    SR->>B: prepareCmd: ./build.sh <version>
    B-->>SR: 5 binaries + tarballs
    SR->>S: ./sign.sh
    loop each of 5 artifacts
        S->>Fulcio: keyless sign-blob (OIDC identity)
        Fulcio-->>S: cert + signature
        S-->>S: write .sig / .pem / .cosign.bundle.json
    end
    SR->>Rel: @semantic-release/github uploads<br/>binaries + signatures as assets
Loading

4. How a consumer verifies a downloaded binary

# Download e.g. rpc_linux_x64.tar.gz and its .cosign.bundle.json from the Release
cosign verify-blob \
  --bundle rpc_linux_x64.tar.gz.cosign.bundle.json \
  --certificate-identity-regexp "https://github.com/device-management-toolkit/rpc-go/.github/workflows/release.yml@.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  rpc_linux_x64.tar.gz

If the file was tampered with, or the signing identity doesn't match, verification fails.


5. Key differences vs orch-ci, summarized

orch-ci rpc-go-sec
Cosign install inside composite action explicit step in release.yml
Where signing runs dedicated sign-binaries matrix job sign.sh inside semantic-release prepareCmd
Artifact set dynamic (job output matrix) fixed 5 artifacts in sign.sh
Signature transport workflow artifacts → merge → registry push attached directly as GitHub Release assets
Cert output filename cosign.cert <artifact>.pem (per-artifact, avoids collisions)
Bundle cosign.bundle.json (single) <artifact>.cosign.bundle.json (per-artifact)
Verify identity open-edge-platform/* device-management-toolkit/rpc-go release workflow

The core signing command (cosign sign-blob --yes --bundle … --output-signature … --output-certificate …) and the keyless OIDC mechanism are identical — we kept the security-critical part the same and only changed the orchestration to fit semantic-release.


6. Testing on a fork (Option A)

Because the real release.yml needs upstream secrets (ROSIE_TOKEN, Docker creds) and does a docker build, we validated only the signing path on a fork via a throwaway test-cosign.yml:

flowchart TD
    T1[push to cosign_bin on fork] --> T2[Install go-licenses + cosign]
    T2 --> T3[./build.sh 0.0.0-test]
    T3 --> T4[./sign.sh — real keyless signing]
    T4 --> T5[cosign verify-blob each artifact]
    T5 --> T6[Upload cosign-signatures artifact]
Loading

This exercises the exact CI path — real GitHub OIDC token, cosign-installer, build.sh, sign.sh, and offline verification — without running semantic-release or needing any secrets. test-cosign.yml is deleted before the upstream PR; only sign.sh + the three edited files ship.

Clone this wiki locally