Sign Jenkins builds as the job
On the TestifySec Platform a Jenkins build can sign with its own identity. A tenant admin registers the Jenkins controller as a CI issuer. After that, each build of a registered job exchanges its OIDC token for a short-lived certificate whose only identity is the job URL. The certificate names no person, and Jenkins holds no long-lived signing key.
The platform pins the controller's public keys at registration and never fetches anything from the controller, so a controller on a private network works. Pinning has a cost: if the controller's key changes, signing stops until an admin registers the new key. Under configuration as code (JCasC) the key changes on every restart unless the credential lives where JCasC does not manage it. Most of this guide is about that.
What you need
- Jenkins with the OpenID Connect Provider plugin, plus
credentials-binding,plain-credentialsandcloudbees-folder. JENKINS_HOMEon a persistent volume, with backups. The signing key is stored there.- An OWNER or ADMIN of your platform tenant who can complete a passkey step-up. Registering an issuer requires a fresh passkey confirmation. API tokens and enrolled agents cannot register one.
cilockon the agents that run signing steps.
Step 1: Put the job URL in the token
The platform admits a token only when its claims are the ones a Jenkinsfile cannot change: sub is the job URL,
build_number is the build number, and build_url is the build URL. Set them in the controller's configuration as
code (this block is global configuration, not a credential, so it is safe in JCasC):
security:
idToken:
buildClaimTemplates:
- {name: sub, format: "^${JOB_URL}", type: string}
- {name: build_number, format: "^${BUILD_NUMBER}", type: integer}
- {name: build_url, format: "^${BUILD_URL}", type: string}
globalClaimTemplates:
- {name: sub, format: "^${JENKINS_URL}", type: string}Do not give pipelines parameters named like these template variables.
Step 2: Create the id token credential once, in a folder
The plugin generates the credential's RSA key when the credential is created, and it offers no way to supply a key.
JCasC re-creates every credential it declares each time it applies, so an idToken declared in JCasC gets a new
key at every controller start and every configuration reload. Leaving it out of JCasC is not enough if it sits in
the system credential store: a JCasC credentials: block replaces that store, and a credential created there by hand
is deleted on the next start.
What keeps the key is a credential in a folder's credential store:
- Create a folder for the jobs that sign, for example
builds, and move or create those jobs inside it. - In that folder, open Credentials and add an OpenID Connect id token credential (
idTokenin JCasC terms). Give it the IDjenkins-oidcand audiencesigstore. Set Issuer to anhttpsURL that names this controller, for examplehttps://jenkins.example.com/oidc/job/builds. The platform registers onlyhttpsissuers. It treats the value as a name and never connects to it, so it does not need to resolve from the platform. - Do not declare this credential in JCasC, and do not let JCasC or Job DSL re-create the folder.
The key is saved in the folder's config.xml, encrypted with the controller's secrets/ directory, and reloaded
unchanged when the controller starts.
What has been checked: on a controller with a persistent JENKINS_HOME and a JCasC file that omits the credential, a
folder credential kept the same key through two restarts and a JCasC reload, in two separate runs. In the same test,
a credential created by hand in the system store was deleted on the first restart, and one declared in JCasC got a
new key at every start and reload.
What has not been checked yet, so test it on your controller before relying on it:
- A certificate issued from a folder credential. The check read the folder issuer's keys from the plugin but did not mint a token from it.
- A folder that Job DSL or JCasC creates at startup. Re-creating the folder may re-create its credential store and its key.
- A controller rebuilt from an empty
JENKINS_HOME. Its key is new whatever the placement. RestoreJENKINS_HOMEfrom backup, or register the new key (see When the key changes).
Step 3: Read the public keys over a channel you trust
The plugin serves the folder issuer's public JSON Web Key Set at <jenkins url>/oidc/job/<folder>/jwks. The keys are
public, but the keys you register decide who can sign as your jobs. If someone substitutes their own key while you
read the document, the platform pins their key and issues certificates for their tokens. Read it only over a channel
that authenticates the controller:
- over HTTPS with certificate verification, for example
curl --fail --proto '=https' https://jenkins.example.com/oidc/job/builds/jwks; or - on the controller host itself, from its loopback address, for example
curl --fail http://127.0.0.1:8080/oidc/job/builds/jwks.
Never pin a document fetched over plain HTTP across a network, or one somebody sent you. It contains public keys only, and it is what the platform pins. Never upload a private key; the platform refuses one.
Step 4: Register the controller
A tenant OWNER or ADMIN registers the issuer with the platform's createCredential GraphQL mutation, from a browser
session that has completed a passkey step-up in the last two minutes:
mutation Register($i: CreateCredentialInput!) {
createCredential(input: $i) { credential { id issuerURL subject } }
}{
"i": {
"name": "jenkins-builds",
"type": "OIDC",
"tenantID": "<your tenant id>",
"scopes": ["attestation:upload"],
"issuerURL": "https://jenkins.example.com/oidc/job/builds",
"audience": "sigstore",
"subject": "http://jenkins.example.com:8080/job/builds/*",
"ciIssuerKind": "JENKINS",
"pinnedJwks": "<the JWKS document from step 3, as a string>"
}
}issuerURLmust equal the credential's Issuer exactly.subjectis the job URL the registration covers: one job URL ending in/, or a URL ending in/*that covers every job below it..../job/builds/*covers.../job/builds/job/app/and never.../job/builds-other/.- A job covered by two registrations of your tenant is refused at signing time. Do not register a second row for the same jobs; revoke the old one first.
- Another tenant cannot register jobs your registration covers, or pin different keys for your issuer.
Step 5: Sign in the pipeline
Bind the credential around the cilock step only. The plugin mints the token when withCredentials binds it, and
cilock exchanges it for a certificate when it signs, after the wrapped command ends:
withCredentials([string(credentialsId: 'jenkins-oidc', variable: 'FT')]) {
sh 'cilock run --step build --platform-url "$PLATFORM_URL" --signer-fulcio-token "$FT" -o build.json -- ./build.sh'
}PLATFORM_URL is your platform's URL. Keep the sh string in single quotes so Groovy does not interpolate the token. A build of a job no registration
covers gets no certificate.
When the key changes
If the key changes (a JCasC-declared credential, a re-created folder, an empty JENKINS_HOME), every signing step
fails. Jenkins shows {"code":3,"message":"There was an error processing the identity token"}. The platform log
says no public keys able to verify jwt.
The platform never trusts a key that no admin pinned, so a person has to act. Revoke the old registration with
revokeCredential, then read the new JWKS as in step 3 and register it as in step 4, with a fresh passkey step-up. Revoke first: if both
registrations are active, every covered job is refused until one is revoked. No build can sign between the two steps.
Next
- Jenkins pipelines without changing the build script for adding cilock to a pipeline.
- Verify in a release gate for checking the build's evidence before release.
Reference generated from the product documentation. Match commands and support details to your installed release.