Repository navigation
Cosign Writeup
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.
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
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.
orch-ci wraps Cosign in a composite action so every repo in the org signs the same way. Two pieces:
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.
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]
Notable design points in orch-ci:
-
permissions: id-token: write+contents: readon 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.
Our release model is different from orch-ci, so we adapted the pattern rather than copy it verbatim.
| 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.
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/>&& ./sign.sh<br/>&& 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)]
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 offlinecosign verify-blob
2. .releaserc.json
-
prepareCmdnow runs./sign.shright after./build.sh(binaries must exist first) and beforedocker build. - Added
*.sig,*.pem,*.cosign.bundle.jsonglobs to the@semantic-release/githubassets, so signatures attach to the GitHub Release next to the binaries.
3. release.yml
- Added
id-token: writeto the jobpermissions(required for keyless). - Added an Install Cosign step (
sigstore/cosign-installerv4.0.0, pinned to the same SHA andcosign-release: v3.0.2as orch-ci) before Semantic Release, socosignis onPATHwhenprepareCmdrunssign.sh.
4. .gitattributes — added sign.sh text eol=lf (matching build.sh) so the
shebang isn't broken by CRLF on checkout.
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
# 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.gzIf the file was tampered with, or the signing identity doesn't match, verification fails.
| 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.
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]
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.