0122. The installer image runs by digest and its signature is verified before the installer Job
Status: Accepted; built; the positive signed path awaits a release tag built by release.yml · Date: 2026-11-04 · Design section: Upgrades (Ichor), Supply chain · Touches: 0059, 0090, 0104, 0105, 0108
Decided: Before the controller creates the installer Job of an Ichor upgrade it pins the installer image to a digest and verifies that digest's keyless cosign signature, offline against an embedded Sigstore trusted root, for the identity https://github.com/maeshinshin/ichor/.github/workflows/release.yml@refs/tags/<spec.version> issued by GitHub Actions. The digest is spec.installerDigest when set, else what the tag <installerRepository>:<spec.version> resolves to at that moment (HEAD on the manifest); the Job's image is <installerRepository>@<digest>, so what was verified is what runs. The only way not to verify is an explicit, reasoned, recorded opt-out, spec.installerSignature: {mode: skip, reason: ...} (ichor upgrade ichor --skip-signature-check "<reason>"), which the controller records in status.installerImage and an Event and which the CLI and the UI show as a warning. A failed resolution or verification pauses the operation with a reason and creates no Job. The apiserver enforces the digest form for the installer Job through the existing Job-image admission policy. The release workflow verifies the signatures it has just made with the same Go code before it finishes.
Context
ADR 0108 signs the three release images and the chart keylessly with cosign and documents cosign verify, but left two things open (its follow-up table): the installer Job's image is installerRepository:version, a tag a registry operator or an attacker with write access can repoint after anyone looked, and nothing in Ichor checks a signature. The installer Job's ServiceAccount holds escalate and bind on ClusterRoles (ADR 0059), so the installer image is the most privileged thing Ichor runs; "as strong as cluster-admin". Audit finding M3 asked for resolution by digest (spec.installerDigest) and a signature check before the Job runs.
internal/imagesig and its sigstore-go dependency existed until commit 3c04d972 for the Cilium image-patch path ADR 0115 later removed. The verification code, the embedded trusted root and the way the bundles are read (OCI referrers, Sigstore bundle v0.3, what cosign 3 writes by default; cosign sign v3.1.3 is the flake's version) are reused here. ADR 0090 says air-gapped installs are out of scope; mirrors are in scope (installerRepository).
Decisions
1. Where the digest comes from
Two sources, one rule: the Job never runs a tag.
spec.installerDigest(new,^sha256:[a-f0-9]{64}$): a person's pin. The controller does not resolve the tag at all, so a pin plusskipneeds no registry access from the controller.- Otherwise the executor resolves
<installerRepository>:<spec.version>with a manifest HEAD request (imagesig.ResolveDigest) when it creates the Job, and uses that digest for both the signature check and the Job. It records{version, repository, digest, verification, signer|reason, resolvedAt}in the newstatus.installerImageand puts the digest on the Job (annotationichor.maesh.dev/installer-image-digest). A later reconcile of the same operation reads the Job, never the registry again; a changedspec.installerDigestwhile a Job exists pauses the operation, as a changedspec.versiondoes (ADR 0059).
Rejected: a release manifest embedded in the CLI or the controller. The controller that runs the upgrade is the old one and cannot hold a digest of a release that did not exist when it was built; the CLI could only name its own version's digests, tying the CLI's version to the target, and the manifest would itself need a trust root. Resolving at upgrade time against the registry the user chose, then verifying a signature that is bound to the digest, needs neither. Rejected: writing the resolved digest into spec (the controller never mutates spec); it goes to status.
The resolve and verify happen once per Job, before it exists: a window between "verified" and "pulled" no longer exists because the pull is by digest (this is the point of M3).
2. Who signs, and the identity
Keyless cosign with the GitHub Actions OIDC token, which release.yml already does for the installer, controller and UI images and the chart (cosign sign --yes <repo>@<digest>, in the publish job, the only job with id-token: write). The certificate's issuer is https://token.actions.githubusercontent.com and its subject alternative name is https://github.com/maeshinshin/ichor/.github/workflows/release.yml@refs/tags/<tag>. imagesig.IchorIdentity(spec.version) is that identity as an anchored, quoted regular expression, so only a signature made by that workflow on the tag equal to spec.version verifies; a valid signature of another tag, another workflow, a branch ref or another repository does not. It is the identity docs/guides/install-with-helm.md gives to cosign verify.
The identity is not configurable. A fork that builds and signs its own installer image runs with skip (recorded) until a decision on a configurable signer is made; making it configurable needs a second trust decision (who may a repository owner name) that this ADR does not take.
A new step in release.yml's publish job, after signing, verifies each of the three images and the installer's signature with ichor's own verifier (internal/tools/verifyimage, built in the build job, which may fetch Go modules, and shipped in the artifact, so publish still fetches none). A format or trust-root mismatch between cosign and sigstore-go therefore fails the release instead of the first upgrade.
3. How verification works offline, with mirrors, and what is trusted
- Offline: the bundle carries the Fulcio certificate, the signed certificate timestamp, the Rekor inclusion proof and entry timestamp, so
sigstore-goverifies it againstinternal/imagesig/trusted_root.json(the Sigstore public-good root, embedded, no TUF fetch) without contacting Sigstore. The only network traffic is the registry: a manifest HEAD, the referrers listing of the digest and the bundle blob. The trusted root changes with an Ichor release; a signature made after Sigstore rotates its keys may not verify on a controller built before it. The recovery isskipwith a reason, then the next Ichor release carries the new root. (Rejected: a root fetched at run time, which makes the verifier depend on a second online service; a ConfigMap override, which is a way to widen trust that needs its own review.) - Mirrors: the signature is about the digest, not the registry. A mirror that carries the image under the same digest and the image's OCI referrers (for example
oras copy --recursive; a plaincrane copyordocker pull/pushdoes not copy referrers) verifies unchanged. A mirror that carries the image only fails with "has no cosign signature" and the operator has two honest ways out: copy the referrers, orskipwith a reason. The controller reads the registry anonymously (the Job's own pull uses the node's credentials, as before); a private mirror that needs a pull secret for the controller's reads isspec.installerDigestplusskiptoday. - Egress: the controller's Cilium egress policy (ADR 0100) gets one more dynamic policy,
ichor-controller-installer-image, maintained by the IchorInstallation reconciler: HTTPS to the host ofspec.installerRepository, pluspkg-containers.githubusercontent.comwhen that host isghcr.io(GHCR redirects blobs there). It exists whenever Ichor's own controller policy exists, whether or not the update check is on, and is the same shape as the release check's. A registry that redirects blobs to another host needs that host named; until it is, the verification fails with the registry's error and the pause says so (skipis the way out).
4. The opt-out for unsigned images
Development and demo builds (v0.0.<N>-g<sha>, pushed to a mirror by make demo-images-push) are not signed. spec.installerSignature is {mode: required|skip, reason}; skip requires a non-empty reason (CEL on the CRD), is a version-subresource write (the same right as changing spec.version, installerRepository and the digest; the settings webhook records who changed it in an Event), and is never inherited: CreateOperation for an Ichor upgrade writes installerDigest and installerSignature exactly as the request says, so a later upgrade without --skip-signature-check writes required again. A standing skip cannot outlive the upgrade it was given for through the CLI or the UI backend; a person who edits the CR by hand owns what they leave there.
When the controller runs a Job under skip it writes status.installerImage.verification: Skipped with the reason and the digest, emits a Warning Event on the IchorInstallation (InstallerSignatureSkipped) and logs it. Shown, not hidden:
ichor upgrade ichor --skip-signature-check "<reason>"prints, before it asks for confirmation, a warning that the installer image is not verified and why, andichor upgrade ichor --installer-digest sha256:...pins. The plan the UI backend returns carries the same warning.- The UI shows a destructive badge "Installer image not verified" in the top bar while
spec.installerSignature.modeisskipor the last run wasSkipped, with the reason and the digest on hover and on the Ichor card of the dashboard.
Rejected: a controller flag or chart value that turns verification off for the whole installation (a silent, standing bypass), a verification mode "warn" (an upgrade that proceeds on a failed check teaches people to ignore it), and treating a dev version string as unsigned automatically (a release-looking tag from a hostile mirror would then pass).
5. Failure
The executor returns a typed error; advanceIchor pauses the operation (ADR 0026) with the reason InstallerImageUnresolved (the tag did not resolve: registry unreachable, tag missing, egress) or InstallerImageUnverified (no signature, a signature that does not verify, wrong identity, wrong digest) and the full message. No Job is created, so the installer ServiceAccount does not run, nothing is snapshotted and nothing is applied. Resuming (ADR 0095's retry annotation, ichor operation retry) re-runs resolve and verify. Nothing ever falls back to the tag or to unverified.
6. The apiserver enforces the digest form
ichor-controller-job-image (ADR 0108) now requires, for a container of the ichor-installer ServiceAccount's Job, image == installerRepository + '@' + installerDigest when spec.installerDigest is set, else installerRepository + '@sha256:<64 chars>'. A controller that was compromised or buggy and created a tag-form Job is refused by the apiserver. The Cilium Helm Job (ichor-cni) keeps installerRepository:version: it runs the installer image of the version already installed, under a ServiceAccount that cannot create roles outside its namespace, and its Job exists in Helm-only installs with no IchorInstallation (see Residual). The chart's structural rule for a chart-only install (admission.jobImageRepository) accepts both forms.
Residual risk and what this does not do
- The first upgrade onto a release that contains this change is made by the older controller, which creates the Job by tag as before. Protection starts with the upgrade after that.
- The installer applies a manifest whose controller and UI images are tags, not digests. A signed installer therefore still pulls
ichor-controller:<tag>andichor-ui:<tag>by tag. Closing that needs the installer image to be built after the other two images are pushed so its embedded manifest names their digests; the build job has no registry access today. Follow-up. - The Cilium Helm Job's installer image is tag-resolved (above).
- No revocation: a signature stays valid after the key-less certificate expires (that is how keyless works); a compromised release is withdrawn by a new release and the operator's
spec.version, not by Ichor. - The trusted root is embedded; see 3.
- The verification reads signatures only from
installerRepository; a signature published at a different repository is not looked for.
Built
internal/imagesig(restored,IchorIdentity,ResolveDigest; the live test for Cilium is gone),internal/tools/verifyimage,release.yml(verify step, build step for the tool).api/v1alpha1/ichorinstallation_types.go:spec.installerDigest,spec.installerSignature,status.installerImage; CRD and chart CRD regenerated. Fields only added.internal/controller:ichor_upgrade.go(resolve, verify, digest Job, Job annotation, record, Event), the pause reasons, the egress policy in the IchorInstallation reconciler.config/admission/controller-jobs-policy.yamland the chart copy;internal/webhook/v1alpha1settings gate (the new fields need theversionsubresource).proto/ichor/v1alpha1/operation.proto(IchorInstallerOptionson plan and create; installer-image fields onGetIchorInstallationResponse),internal/uiapi,internal/cli/upgrade.go,ui/(badge, alert).internal/controller/talosoperation_controller.go: the two refusal reasons setstatus.failureReasonlikeStepFailed, which the CLI, the UI, the retry request (ADR 0095) and the "one unfinished operation" rule key on; a pause with only a condition reason would be resumed by the controller by itself.controllerPolicyAppno longer treats a policy the controller wrote itself as Ichor's installed policy.ichor versionshows the installer image line;PlanOperationwarns on a skip; a new Ichor upgrade replaces the installer settings (a pin or skip is written even when the version is already the target, so "retry with a skip" after a refusal works: delete the paused operation, run the upgrade with the flag).- Docs:
docs/guides/install-with-helm.md("The installer image on an upgrade"),test/harness/ui-demo/README.md(the demo upgrade passes--installer-digestand--skip-signature-check: the controller cannot resolve a demo tag, which only the nodes' containerd mirror serves), ADR 0108's follow-up table.
Tests
Unit: the identity matches only its tag; a signature verified in a virtual Sigstore passes only for the right identity, issuer and digest; a certificate from another CA fails; a registry with no signature, an unparsable bundle and an attestation referrer are refused; ResolveDigest against an in-memory registry. Controller (envtest): the Job carries repo@digest, a pinned digest skips resolution, a failed resolution and a failed verification pause with their reasons and create no Job, skip records Skipped, the reason and a Warning Event, a changed pin pauses. API server (envtest): the Job-image policy accepts the digest form and refuses a tag and a different digest, the CRD refuses skip without a reason and a malformed digest. UI backend: the plan warns on skip, create writes and clears the fields. CLI: the flags and the warning. UI: the badge appears for skip.
Hardware verification (later)
See the report that accompanies this ADR; the positive path with a real signature needs a release tag built by release.yml.
Follow-up (2026-11-05): the manifest the installer embeds names the controller and ichor-ui by digest
The second residual risk above is closed. A verified installer applies the install manifest embedded in it; that manifest now names the controller and ichor-ui images as <repo>:<tag>@sha256:<digest>, so a retagged image is not pulled (the digest wins over the tag when the node pulls; the tag stays for people and for ichor version-style reading). Nothing in Ichor parses these two images' tags.
- The release builds the installer last.
release.ymlnow runsverify,build(the controller and ichor-ui binaries and the verifier; no token),publish-core(pushes those two images, writesbin/digests.txt; the first token job),installer(no token; downloads the digests, renders the manifest withmake build-installer IMAGE_DIGESTS=bin/digests.txt, refuses to embed a manifest that does not name them, compiles the installer binary, and packages the chart with the same digests as its default images), andpublish(pushes the installer and the chart, then signs, verifies and attests all four as before). The job that fetches Go modules and Kustomize still holds no registry token, which is why the render and the installer build are not folded intopublish-coreorpublish. The cost is a second approval of the protectedreleaseenvironment per release (publish-coreandpublish). make build-installertakesIMAGE_DIGESTS=<file of repo@digest lines>(whatimages.shwrites toDIGEST_FILE;hack/pin-image.shreads it, and fails for an image with no digest instead of falling back to a tag). Without it the images stay tags, so a localmake build-installer,make deployandmake testare unchanged.INSTALL_OUTredirects the output.make helm-package IMAGE_DIGESTS=...stamps the digests into a temporary copy of the chart's default repositories (the templates add no tag to a repository that contains an@), sodist/chart/values.yamlin the tree is never edited.images.shgainsbuild-core,push-core,build-installerandpush-installer; Makefile targetsimages-build-core,images-push-core,images-build-installer,images-push-installer.make demo-imagesnow runs all four, so the demo harness exercises the release path and its manifest is digest-pinned (the nodes' containerd mirror serves a pull by digest as it serves one by tag).demo-images-buildanddemo-images-push, the two-step flow documented in the getting-started guide, still render a manifest of tags.- The released CLI (
ichor initapplies the manifest embedded in it) is built outside the workflow, by goreleaser ormake release. The workflow uploads the completebin/digests.txtas the artifactrelease-image-digests(90 days);ICHOR_IMAGE_DIGESTS=<that file> goreleaser releaseandmake release IMAGE_DIGESTS=<that file>embed a manifest with the same digests. A snapshot build or amake releasewithout the file embeds tags, as before. - The Cilium Helm Job (the third residual risk) now runs the installer image the last Ichor upgrade recorded: when
status.installerImageis forspec.versionandspec.installerRepositoryand has a digest, the Job's image is<repository>@<digest>(verified, or recorded as skipped). Without such a record (a freshichor init, a Helm-only install with no IchorInstallation, a version changed by hand) it isrepository:versionas before; closing that case needs a verified digest the controller has not resolved, which would put the resolve-and-verify step on the Cilium path too, and was not done. The Job-image policy already accepted both forms.
Tests: test/harness/ui-demo runs images.sh against an in-memory registry with a stub go (push the two images, check the digest file and the registry's digests, render the manifest with and without digests through make build-installer, embed it and refuse a tag-only one, push the installer and append its digest), hack/pin-image.sh, and make helm-package with digests rendered with helm template; internal/controller tests the Cilium Job image for a matching record, another version, another repository and no digest. actionlint passes on the workflow.
Still open: a tag-form manifest in the two-step demo-images-build flow and in snapshot CLI builds (neither is a release); the first upgrade onto a release with this change still starts from the older controller's tag-form manifest.
Hardware verification (later): push a v* tag and check that the installer image's embedded manifest (ichor installer Job logs, or kubectl get deploy -n ichor-system -o jsonpath='{..image}' after an upgrade) shows @sha256: for the controller and ichor-ui; run make demo-images against the demo registry and an ichor init / ichor upgrade ichor with the digest-pinned manifest to see the nodes pull by digest through the mirror; after an Ichor upgrade, the ichor-cni Job's pod image is ichor-installer@sha256:....
Amendment (2026-11-05): the Cilium Helm Job never runs a tag
The follow-up above left one case open: without a status.installerImage record for the installed version and repository (a fresh ichor init, a Helm-only install, a version changed by hand) the Cilium Helm Job ran repository:version, a tag that can move, under the ichor-cni ServiceAccount, which is close to cluster-admin (ADR 0108). That case is closed, and with it the "residual risk" the Job-image policy carried for the Cilium Job.
cniJobImage(internal/controller/cni_helm.go) returnsrepository@digestor an error. A recorded digest is used as it is (when it is forspec.versionandspec.installerRepository, and isspec.installerDigestwhen that is set: a record that is not the pin is not trusted). Without one the controller callspinInstallerImage, the function the Ichor upgrade uses: it resolves the tag (or takes the pin), verifies the cosign signature with the same verifier and identity, and honoursspec.installerSignature.mode: skip, so one setting governs both Jobs. A Helm-only install has no IchorInstallation; its stand-in (the controller's own version, the default repository) is resolved and verified the same way.- This happens in
AdvanceCNIHelmJob, after the values check and before the Job is created, and only when the Job does not exist yet. On a refusal no Job is started and the error is an*installerImageErrorwith the installer path's reasons (InstallerImageUnresolved,InstallerImageUnverified; a controller without a verifier refuses too). The hand-over reconciler lets go of the cluster lock, sets theCNIValuesAppliedprogress condition to that reason with the message, and requeues; the kube-proxy switch and the chart upgrade return the error, which pauses their operation as a step failure with the same text.NewCNIKubeProxyAdvancerandCNIHandoverReconcilertake theInstallerImagesthe installer path already uses (cmd/main.go). - The Job-image policy (
config/admission/controller-jobs-policy.yamland the chart copy) now holds both Jobs torepository@sha256:<64 hex>(spec.installerDigestwhen set); the Cilium-onlyrepository:versionrule is gone. The chart'sadmission.jobImageRepositoryrule (chart-only installs) requires the same digest form. - Not done (ponytail): the digest resolved here is not written to
status.installerImage, so each new Cilium Job of a Helm-only or fresh install resolves and verifies again (one registry round trip per Job). A skipped check on this path raises no Warning Event of its own; the upgrade's event andstatus.installerImagealready show a skip where there was an upgrade.
Tests: TestCNIJobImageIsADigestTheControllerChecked (record, other version, other repository, pin, skip, unresolvable, unverified, no verifier), TestAdvanceCNIHelmJobStartsNoJobForAnUnverifiedImage, the apiserver policy tests in api/v1alpha1 (the Cilium Job refuses a tag and any digest but the pin).
Hardware verification (later): on a cluster with no status.installerImage (fresh ichor init), enable a Cilium value edit and see the ichor-cni Job's pod image be ichor-installer@sha256:... and an InstallerImageUnverified progress message with an unsigned build and no skip.
Amendment 2026-11-05 (ADR 0130): skipping the signature check no longer needs a reason. spec.installerSignature: {mode: skip} is valid without one (the CEL rule is removed; reason is optional, deprecated and unused), ichor upgrade ichor --skip-signature-check is a boolean flag, the Connect IchorInstallerOptions carries skip_signature (the deprecated skip_signature_reason still means skip when an older client sends it), status.installerImage.reason is no longer written, and the InstallerSignatureSkipped Event, ichor version ("signature NOT checked"), the plan warning and the UI badge say the check was skipped without a reason. Everything else (the digest, the opt-out being explicit and never inherited, the version-subresource right) stands.
Amendment 2026-11-06 (hardware lane J, P2: a fresh ichor init of an unsigned demo build cannot hand Cilium over): since the previous amendment of 2026-11-05 the Cilium Job resolves and verifies the installer's tag when status.installerImage has no record for the installed version, and right after ichor init there never is one (only an Ichor upgrade writes it). On a cluster whose controller cannot reach the registry (the demo: nodes pull through a containerd mirror, the controller does not) CNIHandover went False/InstallerImageUnresolved (Get "https://ghcr.io/v2/": dial tcp ...: i/o timeout), no Job was created, and the demo needed spec.installerDigest and spec.installerSignature.mode: skip patched in by hand. The release flow is unaffected, because ghcr.io is reachable there and the signature is real.
Decision: ichor init takes the two flags of ichor upgrade ichor, --installer-digest sha256:... and --skip-signature-check, and writes them into the IchorInstallation it creates (spec.installerDigest, spec.installerSignature.mode: skip). The writer is init, a CLI creating the object, not the controller (which never mutates spec); the field was already there, so no CRD change. With the pin set pinInstallerImage never resolves the tag, and with skip it never asks for a signature, so the controller needs no network to run the Cilium Job as repository@digest; without the flags nothing changes (the tag is resolved and the signature required). A skipped check is shown at once: init prints a Warning, and spec.installerSignature records it on the object (the Cilium path writes no status.installerImage record of its own, as the previous amendment says). The digest is not derived by init itself: the installer image embeds the CLI and its manifest, so its own digest cannot be inside them (a digest in the binary would change the digest); it comes from DIGEST_FILE of make demo-images (the line ghcr.io/maeshinshin/ichor-installer@sha256:...), as the upgrade of the demo already takes it. The next ichor upgrade ichor replaces both settings with what its own flags say, as before (ADR 0130), so a demo that upgrades keeps passing both flags.
The demo harness (test/harness/ui-demo/README.md, step 1 and 5) now builds with DIGEST_FILE and passes both flags to init; there is no other init call in the repository. A released CLI built outside the release workflow with ICHOR_IMAGE_DIGESTS could embed the installer's digest too; not done (the registry is reachable there). The ichor init -f file has no keys for the two flags yet (ponytail: flags only; a demo is driven by flags).
Tests: TestCreateIchorInstallation_RecordsTheInstallerPin and TestCreateIchorInstallation_SkipIsWarnedAbout and TestInitInstallerFlags (red first: no digest or skip written, no warning, a bad digest accepted), TestCreateIchorInstallation_WithoutAPinLeavesTheInstallerToTheController; in the controller the case "pinned by init with skip: no record and no registry needed" of TestCNIJobImageIsADigestTheControllerChecked (an unreachable registry, an unset status record, the pinned digest and skip: the Job image is repository@digest, nothing resolved or verified).
Hardware verification (later): make demo-images ... DIGEST_FILE=bin/demo-digests.txt, then ichor init ... --installer-digest $D --skip-signature-check on an unsigned demo build with no hand patch: CNIHandover goes True with no InstallerImageUnresolved, the ichor-cni Job's pod image is ichor-installer@$D, and kubectl get ichorinstallation ichor -o yaml shows both fields from the start.