Skip to main content
View Markdown ↗

Copy this page

Select and copy the Markdown below, then paste it into your LLM.

On this page

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-credentials and cloudbees-folder.
  • JENKINS_HOME on 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.
  • cilock on 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:

  1. Create a folder for the jobs that sign, for example builds, and move or create those jobs inside it.
  2. In that folder, open Credentials and add an OpenID Connect id token credential (idToken in JCasC terms). Give it the ID jenkins-oidc and audience sigstore. Set Issuer to an https URL that names this controller, for example https://jenkins.example.com/oidc/job/builds. The platform registers only https issuers. It treats the value as a name and never connects to it, so it does not need to resolve from the platform.
  3. 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. Restore JENKINS_HOME from 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>"
  }
}
  • issuerURL must equal the credential's Issuer exactly.
  • subject is 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

Reference generated from the product documentation. Match commands and support details to your installed release.