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