343 lines
12 KiB
Bash
Executable file
343 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 \
|
|
"${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 <<ALLFILES_INPUT
|
|
$all_files
|
|
ALLFILES_INPUT
|