From fd265f0023923cb9d811483b75dbfd7bfd5f54e5 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 07:22:50 -0700 Subject: [PATCH] more robust - find USB hub on own, build docker image at runtime --- Dockerfile | 7 +- README.md | 209 ++++++++++++++++++++++---- action.yml | 32 ++-- entrypoint.sh | 363 ++++++++++++++++++++++++++++++++++++++------- signer/Dockerfile | 37 +++++ signer/hsm-sign.sh | 130 ++++++++++++++++ signer/test.sh | 88 +++++++++++ 7 files changed, 778 insertions(+), 88 deletions(-) create mode 100644 signer/Dockerfile create mode 100755 signer/hsm-sign.sh create mode 100755 signer/test.sh diff --git a/Dockerfile b/Dockerfile index 72faadc..4e38a0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,8 +5,10 @@ FROM docker:28.3.1-dind-alpine3.22 # This is an alpine-based image +# The HSM's power is switched through the kernel's sysfs port interface (see +# entrypoint.sh), so uhubctl is not needed RUN true && \ - apk add --no-cache curl uhubctl && \ + apk add --no-cache curl && \ apkArch="$(arch)" && \ if [ $apkArch = "x86_64" ]; then apkArch=amd64; fi && \ curl -sLO https://github.com/sigstore/rekor/releases/download/v1.0.1/rekor-cli-linux-${apkArch} && \ @@ -15,5 +17,8 @@ RUN true && \ true COPY entrypoint.sh / +# The signer's build context, built on the host daemon at run time so its +# layers are cached there between runs +COPY signer /signer ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index acd6930..6fafc2a 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,149 @@ -Signs files using an HSM -======================== +Signs with an HSM +================ -Basic Usage +A docker action that signs with a key that never leaves an HSM attached to the +runner host, reached through the host's pcscd. It can: + +* make **detached signatures** of files (RSA PKCS#1 v1.5 over SHA-256), + optionally logged to the [sigstore public transparency log](https://sigstore.dev) +* **sign an APK** (APK signature schemes v2 and v3) with `apksigner` + +or both in one step: the APK is signed first, so the files to sign can include +the signed APK. + +Detached signatures +------------------- + +```yaml + - name: Sign + id: sign + uses: https://git.lerch.org/lobo/action-hsm-sign@v3 + with: + pin: ${{ secrets.HSM_USER_PIN }} + files: dist/* + public_key: 'https://emil.lerch.org/serverpublic.pem' +``` + +`files` is a glob of files in one directory, or `dir/**` for every file under +`dir`. Each signature is written beside its file, as `FILE.sig`. + +If a public key is specified, [rekor](https://github.com/sigstore/rekor) will +be invoked, sending the signature to the sigstore public transparency log. The +signatures are deterministic, so signing the same file again produces the same +signature, and rekor reports the existing entry. + +The action provides the following outputs: + +* `SOURCE_n`: Source file used for the signature +* `SIG_n`: Signature +* `URL_n`: If a public key is specified, the sigstore log url + +Because multiple files can be signed, these outputs have numerical suffixes, +starting at 0, in the order of the sorted file names. In the above example, the +output `${{ steps.sign.outputs.URL_0 }}` would be the url for the first file +signed with this action + +APK signing ----------- ```yaml - name: Sign id: sign - uses: https://git.lerch.org/lobo/action-hsm-sign@v1 + uses: https://git.lerch.org/lobo/action-hsm-sign@v3 with: pin: ${{ secrets.HSM_USER_PIN }} - files: ??? + apk: unsigned/app-release-unsigned.apk + apk_output: dist/app.apk + # Optional: detached signatures of everything in dist/, the signed APK + # included + files: dist/* public_key: 'https://emil.lerch.org/serverpublic.pem' ``` -If a public key is specified, [rekor](https://github.com/sigstore/rekor) will -be invoked, sending the signature to the [sigstore public transparency -log](https://sigstore.dev). +The APK must be zipaligned (AGP's release output already is). -The action provides the following outputs: +An APK signature carries a certificate, and Java (which `apksigner` runs on) +only offers a PKCS#11 key that has a certificate on the token. **Once, before +the first APK is signed**, make a self-signed certificate for the key and store +it beside the key. On the HSM host, with the HSM powered, from a clone of this +repository: -* Source: Source file used for the signature -* Signature: Signature -* URL: If a public key is specified, the URL output provides the sigstore log url +```sh +docker build -t hsm-signer signer +read -rs PKCS11_PIN && export PKCS11_PIN +docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ + -e PKCS11_PIN hsm-signer make-cert '/CN=My App/O=example.org' +``` -Because multiple files can be signed, these outputs have numerical suffixes. -In the above example, the output `${{ steps.sign.outputs.URL_1 }}` would be the -url for the first file signed with this action +It prints the certificate; keep a copy. The certificate is the app's identity +on every device from then on: replacing it means users uninstall and reinstall. +Its 30-year validity does not matter for APKs, whose certificate dates Android +does not check. Running the same container with `list` instead of `make-cert` +shows what Java sees on the token. + +Inputs +------ + +| input | default | | +|----------------|---------|--------------------------------------------------------------------------| +| `files` | | files to make detached signatures of | +| `apk` | | unsigned APK to sign | +| `apk_output` | | where to write the signed APK; required with `apk` | +| `pin` | | the HSM's user PIN (required) | +| `key_id` | `03` | the key's CKA_ID, in hex | +| `slot_index` | `0` | PKCS#11 slot, by position in the slot list | +| `key_alias` | | APK only: which key, when the token has more than one with a certificate | +| `public_key` | | URL of the PEM public key; set it to log detached signatures to sigstore | +| `uhub_control` | `false` | power cycle the HSM on a smart USB hub (below) | + +At least one of `files` and `apk` is required. + +How it works +------------ + +The HSM is only reachable from the host, through pcscd's socket, and this +action's container is not the host. So the action builds the signer image +([signer/](signer)) on the host's docker daemon, and starts a signer container +beside itself for each signature, with `/run/pcscd/pcscd.comm` mounted. A +volume mount would name a host path, so inputs and results travel by +`docker cp`. + +The signer image is tagged `action-hsm-sign-signer:` and only +built when that tag is missing, so the first run with a new version of +[signer/](signer) builds it (a few minutes, from the Debian mirrors) and later +runs reuse it, without needing a build at all. It is not rebuilt for updates to +its Debian base: remove the image (`docker image rm`), and the next run builds +it afresh. Old versions' images stay on the host until removed. + +The signer is Debian with opensc, which reaches the HSM through pcscd, plus +Debian's `apksigner` and OpenSSL's PKCS#11 engine. Its opensc and pcsc-lite +have to speak the host pcscd's protocol, so the signer image should follow the +host's Debian release (`BASE` in [signer/Dockerfile](signer/Dockerfile)). + +[signer/test.sh](signer/test.sh) runs the signer end to end against SoftHSM, +with docker or podman: a token with a bare key, a detached signature checked +with openssl against the token's public key and, given an APK, `make-cert`, +APK signing, `apksigner verify`, and a check that the APK's signing key is the +token's: + +```sh +signer/test.sh # detached only +signer/test.sh app-release-unsigned.apk [signed.apk] +``` + +What it cannot cover is the HSM itself and the host's pcscd. Usage with Smart USB Hubs ------------------------- Many consumer HSMs will "hang" after prolonged usage. To alleviate problems -associated with this, this action can integrate with smart USB hubs to turn -on the hub's port and wait for the OS to recognize the attached HSM before -performing the signing action. +associated with this, this action can integrate with smart USB hubs (hubs with +per-port power switching) to power cycle the hub's port and wait for the OS to +recognize the attached HSM before performing the signing action. Power cycling +also drops any other process on the host that has the HSM open exclusively. -**NOTE: The action will turn off the port on the USB hub when it is done processing** +**NOTE: The action will turn off the port on the USB hub when it is done +processing, including when signing fails or the job is cancelled** To enable this feature, set `uhub_control` to `true`. As this is controlling physical hardware, you will also need a runner set with a max concurrency of 1 @@ -53,19 +160,69 @@ jobs: runs-on: ubuntu-latest-with-hsm ``` -The runner will also need to set environment variables `UHUB_PORT` and -`UHUB_LOCATION` as appropriate. To determine the proper values for these, it is -best to consult [uhubctl -documentation](https://github.com/mvp/uhubctl?tab=readme-ov-file#usage) and run -some command line tests. Updating the previous example: +The port is switched through the kernel's sysfs interface +(`/sys/bus/usb/devices/:1.0/-port/disable`), so the runner host +needs Linux 6.0 or later, and the runner must run containers privileged +(`container.privileged: true` in the runner config). + +The runner also needs environment variables (`runner.envs` in the runner +config) that say where the HSM is: + +* `UHUB_PORT` (required): the hub port the HSM is plugged into +* `UHUB_ID` (recommended): the hub's `vid:pid`, as shown by `lsusb`. The hub's + location is looked up at run time, so the hub can be moved to another USB + port on the host without a config change +* `UHUB_LOCATION` (optional): the hub's location (e.g. `1-1.4`, as shown by + `lsusb -t` or `ls /sys/bus/usb/devices`). Takes precedence over `UHUB_ID` + +If neither `UHUB_ID` nor `UHUB_LOCATION` is set, the action uses the one hub +with a smartcard device on `UHUB_PORT` or, if there is none, the one hub whose +port `UHUB_PORT` is powered off. When the hub cannot be found, the action fails +and lists every hub it can see. For example: + +```yaml +runner: + capacity: 1 + envs: + UHUB_PORT: 4 + UHUB_ID: '366b:0004' +``` + +Updating the first example: ```yaml - name: Sign id: sign - uses: https://git.lerch.org/lobo/action-hsm-sign@v1 + uses: https://git.lerch.org/lobo/action-hsm-sign@v3 with: pin: ${{ secrets.HSM_USER_PIN }} - files: ??? + files: dist/* public_key: 'https://emil.lerch.org/serverpublic.pem' uhub_control: 'true' ``` + +If you need to use the HSM by hand while its port is powered off, a privileged +container can switch it on (adjust hub location and port): + +```sh +docker run --rm --privileged alpine sh -c \ + 'echo 0 > /sys/bus/usb/devices/1-1.4:1.0/1-1.4-port4/disable' +``` + +Other users of the HSM on the runner host must not hold it open exclusively. In +particular, GnuPG's `scdaemon` opens smartcard readers exclusively and keeps +them open, so on the runner host put `disable-scdaemon` in +`~/.gnupg/gpg-agent.conf` (or `pcsc-shared` in `~/.gnupg/scdaemon.conf`). + +Upgrading from v2 +----------------- + +Detached signing is unchanged for callers: `files`, `pin`, `public_key` and +`uhub_control` work as before, and the signatures are the same. Changes: + +* `slot` (which was the key's id) is now `key_id`, default `03` +* `files` is no longer required, as long as `apk` is set +* the signer is built from [signer/](signer) instead of pulling + `git.lerch.org/lobo/pkcs11:1` +* hub control finds the hub at run time (`UHUB_ID`), and needs Linux 6.0 or + later diff --git a/action.yml b/action.yml index 0d5fb75..dcec03e 100644 --- a/action.yml +++ b/action.yml @@ -1,22 +1,36 @@ name: 'HSM Signing' -description: 'Signs using HSM' +description: 'Signs APKs and makes detached signatures of files, with a key held in an HSM' author: 'lobo' inputs: files: - description: 'Files to sign' - required: true - user_pin: + description: 'Files to make detached signatures of (a glob in one directory, or dir/** for all files under dir). Signatures are written beside them as FILE.sig' + required: false + apk: + description: 'An unsigned, zipaligned APK to sign (signature schemes v2 and v3). Signed before files, so files can include apk_output' + required: false + apk_output: + description: 'Where to write the signed APK. Required with apk' + required: false + pin: description: 'User pin for HSM on build server' required: true - slot: - description: 'HSM slot used for signing' + key_id: + description: 'CKA_ID of the key, in hex' required: true - default: 3 + default: '03' + slot_index: + description: 'PKCS#11 slot, by position in the slot list' + required: true + default: '0' + key_alias: + description: 'APK only: the key alias, when the token holds more than one key with a certificate' + required: false + default: '' public_key: - description: 'URL to PEM format public key. Specify only if uploading to sigstore' + description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore' required: false uhub_control: - description: 'If HSM is attached to software controlled power hub, setting this to "true" will power on the HSM during operation' + description: 'If HSM is attached to software controlled power hub, setting this to "true" will power cycle the HSM before signing and power it off afterwards. The runner needs UHUB_PORT set (see README)' required: true default: "false" runs: diff --git a/entrypoint.sh b/entrypoint.sh index e086546..6529925 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,64 +1,328 @@ #!/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 -if [ "${INPUT_UHUB_CONTROL}" != "false" ]; then - if [ -z "${UHUB_LOCATION}" ] || [ -z "${UHUB_PORT}" ]; then - echo "error: UHUB control requested, but runner has not been configured with UHUB_LOCATION and UHUB_PORT environment variables" - exit 255 - fi - uhubctl -a off -p "${UHUB_PORT}" -l "${UHUB_LOCATION}" # Off seems to be reflected immediately - # Capture the number of hidraw devices with the port off - # The way docker works, we can't seem to monitor /dev directory - # But a USB device should show up in dmesg log when this happens - #devs="$(find /dev -maxdepth 1 -name 'hi*' |wc -l)" - devs=$(dmesg |grep "usb ${UHUB_LOCATION}.${UHUB_PORT}" |grep -c "New USB device found") - uhubctl -a on -p "${UHUB_PORT}" -l "${UHUB_LOCATION}" - retries=0 - while [ "$(dmesg |grep "usb ${UHUB_LOCATION}.${UHUB_PORT}" |grep -c "New USB device found")" = "$devs" ] && [ $retries -lt 10 ]; do - # Generally takes a few seconds to settle in - echo "waiting for device connection ($((retries+1)) / 10)" - sleep 1 - retries=$((retries+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 - if [ $retries -ge 10 ]; then - echo "device is not available. Aborting" - exit 1 + 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 -dir="$(dirname "${INPUT_FILES}")" -glob="$(basename "${INPUT_FILES}")" -# Pass these through sort so we can have deterministic output indexing -if [ "${glob}" = "**" ]; then - all_files="$(find "$dir" -type f |sort)" -else - all_files="$(find "$dir" -maxdepth 1 -name "${glob}" -type f |sort)" +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}" - # 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'll create a container, copy - # the file to sign in place, get the signature and copy that back - container="$(docker create \ - -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ - -e INPUT_PIN \ - git.lerch.org/lobo/pkcs11:1 \ - -s --id "${INPUT_SLOT}" -m SHA256-RSA-PKCS -i artifact -o signature --pin env:INPUT_PIN)" - docker cp "$f" "${container}":/home/user/artifact - docker start -a "$container" # let container run, pick up the exit code - ec=$? - if [ $ec -ne 0 ]; then - docker rm "$container" - exit $ec - fi - # We are clear. Copy signature back into the workspace and remove the container - docker cp "${container}":/home/user/signature "${dest_sig}" - docker rm "${container}" + 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}" @@ -77,8 +341,3 @@ while IFS= read -r f; do done <&2 + exit 1 +} + +[ -n "${PKCS11_PIN:-}" ] || die "PKCS11_PIN is not set" +module="${PKCS11_MODULE:-$(find /usr/lib -name opensc-pkcs11.so | head -n 1)}" +[ -f "$module" ] || die "no PKCS#11 module at '$module'" +slot_index="${PKCS11_SLOT_INDEX:-0}" +key_id="${KEY_ID:-03}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +chmod 700 "$work" + +# SunPKCS11's configuration: which library, which slot. +cat > "$work/pkcs11.cfg" < "$work/java.security" + echo "-Djava.security.properties=$work/java.security" +} + +case "${1:-}" in + detached) + [ $# -eq 3 ] || die "usage: hsm-sign detached IN OUT" + # The signature action-hsm-sign has always made, so signatures verify (and match + # sigstore entries) the same across versions: PKCS#1 v1.5 is deterministic. + pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \ + --sign --id "$key_id" --mechanism SHA256-RSA-PKCS --input-file "$2" --output-file "$3" + ;; + + apk) + [ $# -eq 3 ] || die "usage: hsm-sign apk IN.apk OUT.apk" + set -- "$2" "$3" + alias_args="" + [ -n "${KEY_ALIAS:-}" ] && alias_args="--ks-key-alias ${KEY_ALIAS}" + # shellcheck disable=SC2086 # alias_args is two words or none, by construction + JAVA_TOOL_OPTIONS="$(java_security)" apksigner sign \ + --ks NONE --ks-type PKCS11 --ks-pass env:PKCS11_PIN \ + $alias_args \ + --in "$1" --out "$2" + # Proof, in the log, of what was signed and by whom. + apksigner verify --verbose --print-certs "$2" + ;; + + make-cert) + [ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'" + subject="$2" + # OpenSSL's PKCS#11 engine, pointed at the same module, signs the certificate with + # the HSM's key. The PIN is in a file only this process can read, not an argument. + cat > "$work/openssl.cnf" <&2 + exit 2 +} +[ $# -le 2 ] || usage +apk_in="" +apk_out="${2:-}" +if [ $# -ge 1 ]; then + [ -f "$1" ] || usage + apk_in="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +fi +here="$(cd "$(dirname "$0")" && pwd)" +engine="$(command -v docker || command -v podman)" || { echo "need docker or podman" >&2; exit 2; } + +"$engine" build -q -t action-hsm-sign-signer:test "$here" >/dev/null +"$engine" build -q -t action-hsm-sign-signer:softhsm - >/dev/null <<'EOF' +FROM action-hsm-sign-signer:test +USER root +RUN apt-get update && apt-get install -y --no-install-recommends softhsm2 && rm -rf /var/lib/apt/lists/* +USER user +EOF + +out_dir="$(mktemp -d)" +trap 'rm -rf "$out_dir"' EXIT +chmod 777 "$out_dir" + +set -- -v "$out_dir:/out" +if [ -n "$apk_in" ]; then + set -- "$@" -v "$apk_in:/home/user/in.apk:ro" +fi + +# shellcheck disable=SC2016 # expanded inside the container +"$engine" run --rm --entrypoint /bin/sh "$@" action-hsm-sign-signer:softhsm -euc ' + export SOFTHSM2_CONF="$HOME/softhsm2.conf" + mkdir -p "$HOME/tokens" + echo "directories.tokendir = $HOME/tokens" > "$SOFTHSM2_CONF" + export PKCS11_MODULE="$(find /usr/lib -name libsofthsm2.so | head -n 1)" + export PKCS11_PIN=123456 KEY_ID=03 + softhsm2-util --init-token --free --label hsm-sign-test --pin "$PKCS11_PIN" --so-pin 87654321 >/dev/null + # A key and nothing else, as on the real HSM before make-cert. + pkcs11-tool --module "$PKCS11_MODULE" --login --pin env:PKCS11_PIN \ + --keypairgen --key-type rsa:4096 --id "$KEY_ID" --label test-key >/dev/null + pkcs11-tool --module "$PKCS11_MODULE" --read-object --type pubkey --id "$KEY_ID" | + openssl pkey -pubin -inform DER -out "$HOME/pub.pem" + want="$(openssl pkey -pubin -in "$HOME/pub.pem" -outform DER | sha256sum | cut -d" " -f1)" + + echo "== detached" + echo "an artifact" > artifact + hsm-sign detached artifact /out/artifact.sig + # What rekor and users do with the published public key. + openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact + + [ -f in.apk ] || exit 0 + echo "== make-cert" + hsm-sign make-cert "/CN=hsm-sign test signer" > /out/cert.pem + openssl x509 -in /out/cert.pem -noout -subject -fingerprint -sha256 + echo "== apk" + hsm-sign apk in.apk /out/signed.apk + echo "== independent verify" + apksigner verify --verbose /out/signed.apk + # The APK is signed by the key, not merely by a certificate naming it: the public key + # apksigner reports is the token'"'"'s. + got="$(apksigner verify --verbose --print-certs /out/signed.apk | sed -n "s/^Signer #1 public key SHA-256 digest: //p")" + echo "token public key sha256: $want" + echo "APK signer key sha256: $got" + [ "$want" = "$got" ] + ' +echo "test.sh: detached signature verified" +if [ -n "$apk_in" ]; then + if [ -n "$apk_out" ]; then + cp "$out_dir/signed.apk" "$apk_out" + fi + echo "test.sh: signed and verified $(basename "$apk_in")" +fi