#!/bin/sh # Sign with the HSM: an APK (input apk), detached signatures of files (input files), # or both, APK first, so a files glob can include the signed APK. Builds the signer # image (./signer) on the host's docker daemon, powers the HSM on if asked, and for # each signature starts a signer container beside this one with the host's pcscd # socket, copying the input in and the result out. Detached signatures can also be # logged to sigstore. # # There is no concurrency control here. We are relying on the fact that # the runner on the host is set to a max capacity of 1 # --- HSM power control ------------------------------------------------------- # # The HSM is on port UHUB_PORT of a hub that can switch each port's power. The # kernel names the port by the hub's location (1-1.4 and the like), which # changes when the hub is plugged in somewhere else, so it is found at run time: # # UHUB_LOCATION if set, used as given (checked to be a hub with that port) # UHUB_ID else, if set: the hub with this vid:pid (as lsusb shows it) # neither the one hub with a smartcard on port UHUB_PORT or, failing # that, the one hub whose port UHUB_PORT has no power # # Docker gives a container a copy of /dev made when it starts, so devices coming # and going cannot be seen there. /sys/bus/usb is live, so that is where this # watches the HSM go away and come back. Switching the power needs Linux 6.0 or # later and a privileged container, for /sys to be writable. uhub_sys=/sys/bus/usb/devices uhub_location="" uhub_port="" uhub_switched=false uhub_die() { echo "error: $*" >&2 exit 1 } # The sysfs directory of port $2 of the hub at location $1. A root hub's # location is its bus number: location 1 is device usb1, ports under 1-0:1.0. uhub_port_dir() { case "$1" in *-*) echo "${uhub_sys}/$1:1.0/$1-port$2" ;; *) echo "${uhub_sys}/$1-0:1.0/usb$1-port$2" ;; esac } # The location of a hub, from its device directory (1-1.4, usb1). uhub_location_of_hub() { _ul="$(basename "$1")" echo "${_ul#usb}" } # The location of the hub a port directory belongs to. uhub_location_of_port() { _ul="$(basename "$1")" _ul="${_ul%-port*}" echo "${_ul#usb}" } uhub_is_hub() { [ "$(cat "$1/bDeviceClass" 2>/dev/null)" = 09 ] } uhub_attached() { [ -e "$1/device" ] } uhub_detached() { ! uhub_attached "$1" } # Whether the device on a port has a smartcard (CCID, class 0b) interface. uhub_smartcard() { for _uc in "$1"/device/*:*/bInterfaceClass; do if [ "$(cat "$_uc" 2>/dev/null)" = 0b ]; then return 0 fi done return 1 } # The kernel's view of the port's power (Linux 6.0 and later). uhub_unpowered() { [ "$(cat "$1/disable" 2>/dev/null)" = 1 ] } # Every hub, and what is on its port UHUB_PORT. For error messages. uhub_list() { echo "hubs (location vid:pid product: port ${UHUB_PORT}):" for _ud in "${uhub_sys}"/*; do if uhub_is_hub "$_ud"; then _ul="$(uhub_location_of_hub "$_ud")" _up="$(uhub_port_dir "$_ul" "${UHUB_PORT}")" if [ ! -d "$_up" ]; then _us="no such port" elif uhub_smartcard "$_up"; then _us="smartcard" elif uhub_attached "$_up"; then _us="another device" elif uhub_unpowered "$_up"; then _us="no power" else _us="nothing attached" fi echo " ${_ul} $(cat "$_ud/idVendor"):$(cat "$_ud/idProduct") $(cat "$_ud/product" 2>/dev/null): ${_us}" fi done } # Sets uhub_location and uhub_port, as described at the top of this block. uhub_find() { _uhubs="" if [ -n "${UHUB_LOCATION:-}" ]; then _uby="UHUB_LOCATION=${UHUB_LOCATION}" _uhubs="${UHUB_LOCATION}" elif [ -n "${UHUB_ID:-}" ]; then _uby="UHUB_ID=${UHUB_ID}" _uid="$(echo "${UHUB_ID}" | tr 'A-F' 'a-f')" for _ud in "${uhub_sys}"/*; do if uhub_is_hub "$_ud" && [ "$(cat "$_ud/idVendor"):$(cat "$_ud/idProduct")" = "${_uid}" ]; then _uhubs="${_uhubs} $(uhub_location_of_hub "$_ud")" fi done else _uby="a smartcard on port ${UHUB_PORT}" for _up in "${uhub_sys}"/*/*-port"${UHUB_PORT}"; do if uhub_smartcard "$_up"; then _uhubs="${_uhubs} $(uhub_location_of_port "$_up")" fi done if [ -z "${_uhubs}" ]; then _uby="port ${UHUB_PORT} having no power" for _up in "${uhub_sys}"/*/*-port"${UHUB_PORT}"; do if uhub_detached "$_up" && uhub_unpowered "$_up"; then _uhubs="${_uhubs} $(uhub_location_of_port "$_up")" fi done fi fi # shellcheck disable=SC2086 # one word per hub set -- ${_uhubs} if [ $# -eq 0 ]; then uhub_list >&2 uhub_die "no hub found by ${_uby}" fi if [ $# -gt 1 ]; then uhub_list >&2 uhub_die "more than one hub found by ${_uby} ($*): set UHUB_ID or UHUB_LOCATION on the runner" fi if [ ! -d "$(uhub_port_dir "$1" "${UHUB_PORT}")" ]; then uhub_list >&2 uhub_die "$1 (from ${_uby}) is not a hub with a port ${UHUB_PORT}" fi uhub_location="$1" uhub_port="$(uhub_port_dir "${uhub_location}" "${UHUB_PORT}")" echo "HSM: port ${UHUB_PORT} of hub ${uhub_location}, found by ${_uby}" } # Runs "$@" once a second until it succeeds, giving up after 10 tries. uhub_wait() { _uw="$1" shift _ui=0 until "$@"; do _ui=$((_ui + 1)) if [ "${_ui}" -gt 10 ]; then return 1 fi echo "waiting for ${_uw} (${_ui} / 10)" sleep 1 done } # Switches the port off (1) or on (0) through the kernel's port "disable" file, # which disconnects the HSM or has it enumerated as it does so. Not uhubctl: # Alpine's is built without its sysfs support, so it only sends the hub the # request, and the kernel never hears that the HSM went away. uhub_power() { echo "$1" >"${uhub_port}/disable" } uhub_is_off() { uhub_detached "${uhub_port}" && uhub_unpowered "${uhub_port}" } # Power the HSM off and on again, and wait for it to connect. uhub_on() { if [ -z "${UHUB_PORT:-}" ]; then uhub_die "UHUB control requested, but the runner has no UHUB_PORT environment variable" fi uhub_find # From here on the port is switched off again when the script exits. uhub_switched=true echo "HSM: switching the port off" uhub_power 1 || uhub_die "could not write ${uhub_port}/disable: this needs Linux 6.0 or later and a privileged container" # The kernel reads the power state back from the hub, so this also catches # hubs that take the request and leave the power on. uhub_wait "the port to switch off" uhub_is_off || uhub_die "port ${UHUB_PORT} of hub ${uhub_location} still has power or a device after switching it off" # Long enough off for the HSM to reset. sleep 1 echo "HSM: switching the port on" uhub_power 0 || uhub_die "could not write ${uhub_port}/disable" uhub_wait "the HSM to connect" uhub_smartcard "${uhub_port}" || uhub_die "no smartcard on port ${UHUB_PORT} of hub ${uhub_location} after switching it on" echo "HSM: connected" } # Power the HSM off, if uhub_on switched it. For the exit trap. uhub_off() { if [ "${uhub_switched}" = true ]; then echo "HSM: switching the port off" uhub_power 1 || echo "warning: could not switch off port ${UHUB_PORT} of hub ${uhub_location}" >&2 fi } # --- end HSM power control --------------------------------------------------- container="" cleanup() { if [ -n "${container}" ]; then docker rm -f "${container}" >/dev/null 2>&1 fi # Whatever happened, so a failed run does not leave the HSM powered uhub_off } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM die() { echo "error: $*" >&2 exit 1 } # Runs `hsm-sign COMMAND` in a signer container. We can't use a volume mount # because it will use the host volume, and we're not on the host, but in a # container. So we create the container, copy the input in as /home/user/NAME_IN, # run it, and copy /home/user/NAME_OUT back. # usage: run_signer COMMAND IN OUT NAME_IN NAME_OUT run_signer() { container="$(docker create \ -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ -e PKCS11_PIN -e PKCS11_SLOT_INDEX -e KEY_ID -e KEY_ALIAS \ "${signer}" "$1" "/home/user/$4" "/home/user/$5")" || die "could not create the signer container" docker cp "$2" "${container}:/home/user/$4" || die "could not copy $2 into the signer" # let container run, pick up the exit code. The exit trap removes the container docker start -a "${container}" || exit $? docker cp "${container}:/home/user/$5" "$3" || die "could not copy the result to $3" docker rm "${container}" >/dev/null container="" } # Pass these through sort so we can have deterministic output indexing list_files() { dir="$(dirname "${INPUT_FILES}")" glob="$(basename "${INPUT_FILES}")" if [ "${glob}" = "**" ]; then find "$dir" -type f |sort else find "$dir" -maxdepth 1 -name "${glob}" -type f |sort fi } if [ -z "${INPUT_FILES:-}" ] && [ -z "${INPUT_APK:-}" ]; then die "nothing to sign: set files, apk, or both" fi if [ -n "${INPUT_APK:-}" ]; then [ -n "${INPUT_APK_OUTPUT:-}" ] || die "apk is set, but apk_output is not" [ -f "${INPUT_APK}" ] || die "no APK at ${INPUT_APK}" elif [ -n "${INPUT_FILES:-}" ]; then # With an APK to sign first, the glob is expanded after, to include it all_files="$(list_files)" [ -n "${all_files}" ] || die "no files match ${INPUT_FILES}" fi # Before the HSM is powered, so it is not on through a cold build, and a # failed build does not cycle it. The image is tagged with a hash of its build # context and only built when that tag is missing: a build needs a buildkit # session with the host daemon, which times out when the host is busy, so a # run with an unchanged signer should not need one. (So the image is also not # rebuilt for base image updates: remove it, and the next run builds afresh.) signer="action-hsm-sign-signer:$(cd /signer && find . -type f -exec sha256sum {} + | sort -k 2 | sha256sum | cut -c1-12)" if docker image inspect "${signer}" >/dev/null 2>&1; then echo "Signer image ${signer} is already built" else echo "Building signer image ${signer}" docker build -q -t "${signer}" /signer || die "could not build the signer image" fi # The PIN reaches the signer as an environment variable, by name: never an # argument export PKCS11_PIN="${INPUT_PIN:-}" export PKCS11_SLOT_INDEX="${INPUT_SLOT_INDEX:-0}" export KEY_ID="${INPUT_KEY_ID:-03}" export KEY_ALIAS="${INPUT_KEY_ALIAS:-}" if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then uhub_on fi if [ -n "${INPUT_APK:-}" ]; then echo "Signing APK ${INPUT_APK}. Signed APK destination: ${INPUT_APK_OUTPUT}" mkdir -p "$(dirname "${INPUT_APK_OUTPUT}")" run_signer apk "${INPUT_APK}" "${INPUT_APK_OUTPUT}" in.apk out.apk if [ -n "${INPUT_FILES:-}" ]; then all_files="$(list_files)" [ -n "${all_files}" ] || die "no files match ${INPUT_FILES}" fi fi if [ -z "${INPUT_FILES:-}" ]; then exit 0 fi i=0 while IFS= read -r f; do sign_dir="$(dirname "$f")" sign_file="$(basename "$f")" dest_sig="${sign_dir}/${sign_file}.sig" echo "Signing file $f. Signature file destination: ${dest_sig}" run_signer detached "$f" "${dest_sig}" artifact signature if [ -n "${INPUT_PUBLIC_KEY}" ]; then echo "Public key url specified. Uploading to sigstore public transparency log" echo "Fetching key from ${INPUT_PUBLIC_KEY}" curl -sLo /tmp/public_key "${INPUT_PUBLIC_KEY}" ec=$?; if [ $ec -ne 0 ]; then exit $ec; fi output=$(rekor upload --artifact "$f" --signature "${dest_sig}" --pki-format x509 --public-key /tmp/public_key) ec=$?; echo "$output"; if [ $ec -ne 0 ]; then exit $ec; fi # Index will not be there if the entry already exists # echo "INDEX_${i}=$(echo "$output"|cut -d, -f1|cut -d\ -f5)" >> "${GITHUB_OUTPUT}" # The parsing, though, is identical echo "URL_${i}=$(echo "$output"|cut -d: -f2-|cut -d\ -f2)" >> "${GITHUB_OUTPUT}" fi echo "SOURCE_${i}=${f}" >> "${GITHUB_OUTPUT}" echo "SIG_${i}=${dest_sig}" >> "${GITHUB_OUTPUT}" i=$((i+1)) done <