@@ -48,6 +48,7 @@
"CONTAINER_TAGS" : ["latest"],
"CONTAINER_TAG_CMDS" : [],
"CONTAINER_IMAGE_MAP" : {},
+ "CONTAINER_COSIGN_KEY" : "",
"extravars" : [
"SANITY_TESTED_DISTROS = ''",
"BB_HASHSERVE = '${AUTOBUILDER_HASHSERV}'",
@@ -15,6 +15,9 @@
# CONTAINER_TAG_CMDS - extra shell to populate _EXTRA_TAGS
# CONTAINER_VERSION_RECIPE - recipe whose PV provides the version tag
# CONTAINER_AUTH_CONFIG - registry auth file staged into the guest
+# CONTAINER_COSIGN_KEY - cosign private key path; when set, each
+# successfully pushed image is signed with
+# cosign (otherwise signing is skipped)
#
import subprocess
@@ -53,6 +56,10 @@ if not auth_config:
else:
auth_config = "${HOME}/.docker/config.json"
+# Signing is conditional: only when CONTAINER_COSIGN_KEY points at a cosign
+# private key. When unset, no cosign sysroot is prepared and no signing runs.
+cosign_key = utils.getconfigvar("CONTAINER_COSIGN_KEY", ourconfig, args.target, args.stepnum)
+
utils.printheader("Pushing container images %s" % list(container_images.keys()))
script = [
@@ -124,6 +131,99 @@ script = [
" _SKOPEO_READY=1",
"}",
]
+
+# Cosign signing helpers, only emitted when a signing key is configured.
+#
+# prepare_cosign() is lazy like prepare_skopeo()/start_vm(): it populates the
+# cosign-native sysroot once and logs cosign in to every target registry. The
+# push paths authenticate via the auth file directly (--dest-authfile /
+# --config), but cosign authenticates through its own credential store, so we
+# must 'cosign login' explicitly. CONTAINER_REGISTRIES entries carry a
+# namespace path (e.g. quay.io/ticotimo), but the auth file is keyed by the
+# bare registry host and 'cosign login' only accepts a host authority (a path
+# fails with "registries must be valid RFC 3986 URI authorities"). So we strip
+# each entry to its host ('${reg%%/*}') for both the credential lookup and the
+# login: read auths.<host>.auth from the auth file, base64-decode it into
+# 'user:password', and feed the password on stdin (--password-stdin) so it
+# never appears in the process table.
+#
+# COSIGN_PASSWORD is exported (defaulting to empty) so cosign reads the
+# passphrase from the environment and never falls through to an interactive
+# prompt under the autobuilder (no PTY) — without it cosign tries to read the
+# passphrase from the terminal and dies with "inappropriate ioctl for device".
+# The real value, if the key is encrypted, must be present in the build
+# environment.
+#
+# sign_image() signs by digest ($2) so cosign does not warn about signing a
+# mutable tag, while still pinning the index a consumer verifies. The digest
+# MUST be the digest the tag resolves to:
+# - multi-arch: the manifest-list/index digest, captured at push time from
+# 'skopeo copy --all --digestfile' (the digest of the list it pushed).
+# - single-arch: the lone manifest digest, which 'skopeo inspect' returns
+# correctly (no list, so no platform ambiguity) — used when $2 is empty.
+# 'cosign sign --recursive' then signs that digest and every child manifest.
+#
+# IMPORTANT: do NOT feed 'skopeo inspect' a multi-arch tag for the sign digest.
+# Without --raw it resolves to the host platform's CHILD manifest, so signing
+# image@<that-digest> signs one architecture and leaves the index unsigned —
+# why signed multi-arch images can still show as "Unsigned". Hence the index
+# digest comes from --digestfile, not inspect.
+if cosign_key:
+ login_block = """ for _reg in %s; do
+ _host=${_reg%%%%/*}
+ _creds=$(python3 -c 'import base64,json,os,sys
+a=json.load(open(os.path.expandvars(sys.argv[1])))
+e=a.get("auths",{}).get(sys.argv[2]) or {}
+t=e.get("auth")
+sys.stdout.write(base64.b64decode(t).decode() if t else "")' "%s" "$_host")
+ if [ -z "$_creds" ]; then
+ echo "WARNING: no credentials for $_host in auth file, skipping cosign login"
+ continue
+ fi
+ _cuser=${_creds%%%%:*}
+ _cpass=${_creds#*:}
+ printf '%%s' "$_cpass" | oe-run-native cosign-native cosign login "$_host" -u "$_cuser" --password-stdin
+ done""" % (" ".join(registries), auth_config)
+ script += [
+ "export COSIGN_PASSWORD=\"${COSIGN_PASSWORD:-}\"",
+ "_COSIGN_READY=0",
+ "prepare_cosign() {",
+ " if [ \"$_COSIGN_READY\" = 1 ]; then return 0; fi",
+ " bitbake cosign-native -c addto_recipe_sysroot",
+ login_block,
+ " _COSIGN_READY=1",
+ "}",
+ # Track digests already signed in this run so the same artefact is
+ # signed only once (see sign_image).
+ "_SIGNED_REFS=\"\"",
+ # $1 = <registry>/<image>:<tag> just pushed; $2 = its digest (the index
+ # digest from 'skopeo copy --digestfile' on the multi-arch path). When
+ # $2 is empty (single-arch), resolve the lone manifest digest with
+ # 'skopeo inspect' — correct there since there is no list. oe-run-native
+ # prints 'Getting sysroot...' to stdout, so grep the digest out.
+ "sign_image() {",
+ " prepare_skopeo",
+ " prepare_cosign",
+ " _SIG_DIGEST=\"$2\"",
+ " if [ -z \"$_SIG_DIGEST\" ]; then _SIG_DIGEST=$(oe-run-native skopeo-native skopeo inspect --authfile %s --format '{{.Digest}}' docker://$1 | grep -oE 'sha256:[0-9a-f]{64}' | tail -n1); fi" % auth_config,
+ " if [ -z \"$_SIG_DIGEST\" ]; then echo \"WARNING: could not resolve digest for $1, skipping cosign sign\"; return 0; fi",
+ " _SIG_REF=\"${1%:*}@${_SIG_DIGEST}\"",
+ " case \" $_SIGNED_REFS \" in *\" $_SIG_REF \"*) return 0 ;; esac",
+ " _SIGNED_REFS=\"$_SIGNED_REFS $_SIG_REF\"",
+ # Sign by digest; --recursive also signs each child manifest. Treat a
+ # transparency-log conflict ('already exists' / HTTP 409, which also
+ # recurs on rebuilds that reproduce the same digest) as success so
+ # signing stays idempotent instead of aborting the step.
+ " _sign_out=$(oe-run-native cosign-native cosign sign --recursive --key %s \"$_SIG_REF\" 2>&1) || {" % cosign_key,
+ " case \"$_sign_out\" in",
+ " *\"already exists\"*|*createLogEntryConflict*) echo \"cosign: $_SIG_REF already in transparency log, treating as signed\" ;;",
+ " *) echo \"$_sign_out\" >&2; return 1 ;;",
+ " esac",
+ " }",
+ " echo \"$_sign_out\"",
+ "}",
+ ]
+
tag_cmds = utils.getconfiglist("CONTAINER_TAG_CMDS", ourconfig, args.target, args.stepnum)
version_recipe = utils.getconfigvar("CONTAINER_VERSION_RECIPE", ourconfig, args.target, args.stepnum)
for recipe, image in container_images.items():
@@ -205,12 +305,19 @@ for recipe, image in container_images.items():
script.append(" echo \"WARNING: %s did not build (no OCI image at $_OCI_MULTIARCH_OUTPUT), skipping push\"" % recipe)
script.append(" else")
script.append(" prepare_skopeo")
+ # --digestfile records the index digest skopeo pushed, so signing can pin
+ # it directly (see sign_image); only needed when signing is enabled.
+ digestfile = ' --digestfile "$_DGSTFILE"' if cosign_key else ""
+ if cosign_key:
+ script.append(" _DGSTFILE=$(mktemp)") # tiny tmp file, assumes autobuilder workdir is ephemeral
for registry in registries:
script += [
" for _tag in $_TAGS; do",
- " oe-run-native skopeo-native skopeo copy --all --dest-authfile %s oci:${_OCI_MULTIARCH_OUTPUT} docker://%s/%s:${_tag}" % (auth_config, registry, image),
- " done",
+ " oe-run-native skopeo-native skopeo copy --all%s --dest-authfile %s oci:${_OCI_MULTIARCH_OUTPUT} docker://%s/%s:${_tag}" % (digestfile, auth_config, registry, image),
]
+ if cosign_key:
+ script.append(" sign_image %s/%s:${_tag} \"$(cat \"$_DGSTFILE\")\"" % (registry, image))
+ script.append(" done")
script.append(" fi")
script.append("else")
# Single-arch: import the ${recipe}-latest-oci artefact into the memres VM
@@ -225,8 +332,10 @@ for recipe, image in container_images.items():
" for _tag in $_TAGS; do",
" %s-$(arch) vimport ${_DEPLOY_DIR_IMAGE}/%s-latest-oci %s/%s:${_tag}" % (runtime, recipe, registry, image),
" %s-$(arch) push %s/%s:${_tag}" % (runtime, registry, image),
- " done",
]
+ if cosign_key:
+ script.append(" sign_image %s/%s:${_tag}" % (registry, image))
+ script.append(" done")
script.append(" fi")
script.append("fi")
# Tear-down is handled by the EXIT trap installed above.
Introduce CONTAINER_COSIGN_KEY, a cosign private key path that gates signing. When unset (the default) no cosign sysroot is prepared and signing is skipped entirely. When set, every image that is successfully pushed is signed in place: - prepare_cosign() lazily populates the cosign-native sysroot (bitbake cosign-native -c addto_recipe_sysroot) and logs cosign in to each target registry. CONTAINER_REGISTRIES entries carry a namespace path (e.g. quay.io/ticotimo), but the auth file is keyed by the bare registry host and 'cosign login' only accepts a host authority, so each entry is stripped to its host ('${reg%%/*}') for both the credential lookup and the login: auths.<host>.auth is base64-decoded into user:password and the password is fed on stdin (--password-stdin). - sign_image() signs the image by digest with 'cosign sign --recursive --key' (which also covers every child manifest of a multi-arch index) so cosign does not warn about signing a mutable tag. The digest must be the one the tag resolves to: the multi-arch index digest is taken from 'skopeo copy --all --digestfile' at push time, and the single-arch manifest digest from 'skopeo inspect' (correct there, as there is no list). Signing 'skopeo inspect' on a multi-arch tag is avoided because without --raw it returns the host platform's child digest, which would sign one arch and leave the index unsigned. Signing is deduped per digest within the run, and a transparency-log conflict ('entry already exists' / HTTP 409, which also recurs on rebuilds that reproduce the same digest) is treated as success so signing stays idempotent rather than aborting the step. Signing runs in both push paths (multiarch skopeo-native and single-arch memres VM) and only after a successful push, so a missing/failed-build artefact is still skipped rather than signed. COSIGN_PASSWORD is exported (defaulting to empty) so cosign reads the passphrase from the environment instead of falling through to an interactive prompt (which dies with "inappropriate ioctl for device" under the autobuilder); an encrypted key requires the real value to be present in the build environment. Signed-off-by: Tim Orling <tim.orling@konsulko.com> --- config.json | 1 + scripts/run-push-containers | 115 +++++++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-)