action-hsm-sign/entrypoint.sh

344 lines
12 KiB
Bash
Executable file

#!/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 -e APK_CERT_SHA256 \
"${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:-}"
export APK_CERT_SHA256="${INPUT_APK_CERT_SHA256:-}"
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 <<ALLFILES_INPUT
$all_files
ALLFILES_INPUT