more robust - find USB hub on own, build docker image at runtime

This commit is contained in:
Emil Lerch 2026-10-04 07:22:50 -07:00
parent 83e6f9074c
commit fd265f0023
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 778 additions and 88 deletions

View file

@ -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"]

209
README.md
View file

@ -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:<hash of 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/<hub>:1.0/<hub>-port<N>/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

View file

@ -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:

View file

@ -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 <<ALLFILES_INPUT
$all_files
ALLFILES_INPUT
if [ "${INPUT_UHUB_CONTROL}" != "false" ]; then
# Turn off the port when we're done
uhubctl -a off -p "${UHUB_PORT}" -l "${UHUB_LOCATION}"
fi

37
signer/Dockerfile Normal file
View file

@ -0,0 +1,37 @@
# The signer: the container that talks to the HSM, started beside the action's own
# container by entrypoint.sh. See hsm-sign.sh for what it does.
#
# Debian with opensc, reaching the HSM through the host's pcscd socket (mounted at run
# time) as an unprivileged uid 1000. Plus Debian's apksigner, which signs APKs through
# Java's SunPKCS11 provider and so needs nothing but opensc's PKCS#11 module, and
# OpenSSL's PKCS#11 engine, used once to make the certificate an APK signature has to
# carry (hsm-sign.sh, make-cert).
#
# The action builds this on the host's docker daemon at run time, so the image always
# matches the action's version, and the layers are cached there between runs. opensc
# and pcsc-lite have to speak the host pcscd's socket protocol, so this follows the
# host's Debian release (nas2: bookworm). That is the one thing here test.sh cannot show:
# it uses SoftHSM, with no pcscd.
#
# BASE is an argument so the image can follow the host if it moves.
ARG BASE=debian:bookworm
FROM ${BASE}
RUN apt-get update && \
apt-get install -y --no-install-recommends \
opensc openjdk-17-jre-headless libapksig-java openssl libengine-pkcs11-openssl ca-certificates && \
# Debian's apksigner package depends on the desktop JRE (default-jre: AWT, GTK, Mesa,
# X11, icon themes - 115 packages instead of 42) though it is a command-line tool that
# runs on the headless one. So its files are unpacked rather than the package
# installed: one jar and its wrapper script, and the headless JRE and libapksig it
# needs are installed above. dpkg's database never hears of it, so there is no
# broken dependency left for a later apt-get to trip on.
cd /tmp && apt-get download apksigner && dpkg-deb -x apksigner_*.deb / && rm apksigner_*.deb && \
useradd -m -u 1000 user && \
rm -rf /var/lib/apt/lists/*
COPY hsm-sign.sh /usr/local/bin/hsm-sign
USER user
WORKDIR /home/user
ENTRYPOINT ["/usr/local/bin/hsm-sign"]

130
signer/hsm-sign.sh Executable file
View file

@ -0,0 +1,130 @@
#!/bin/sh
# Sign with a key held in an HSM, through PKCS#11.
#
# hsm-sign detached IN OUT a detached signature of IN (RSA PKCS#1 v1.5 over
# SHA-256), written to OUT
# hsm-sign apk IN.apk OUT.apk sign an APK (signature schemes v2 and v3), then verify
# hsm-sign make-cert SUBJECT one-time setup for apk: a self-signed certificate for
# the key, written to the token beside it, and printed
# as PEM
# hsm-sign list what Java sees on the token, for diagnosing an alias
#
# Environment:
# PKCS11_PIN the user PIN (required)
# PKCS11_MODULE the PKCS#11 library; default: opensc's
# PKCS11_SLOT_INDEX which slot, by position in the slot list; default 0
# KEY_ID the key's CKA_ID in hex; default 03
# KEY_LABEL the label make-cert gives the certificate; default apk-cert
# KEY_ALIAS which key apksigner uses, when the token holds more than one
#
# Why apk needs a certificate on the token: an APK signature embeds the signer's
# certificate, and Java's PKCS#11 key store only offers a private key that has a
# certificate with the same CKA_ID. A key used only for detached signatures has none
# until make-cert writes one. Android pins an app to its signing certificate for good,
# so make-cert runs once and its certificate is kept.
set -eu
die() {
echo "hsm-sign: $*" >&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" <<EOF
name = HSM
library = $module
slotListIndex = $slot_index
EOF
# What apksigner and keytool need to reach the token through Java. The PIN goes by
# environment variable name, never on a command line.
java_store() {
printf '%s\n' -storetype PKCS11 -providerClass sun.security.pkcs11.SunPKCS11 \
-providerArg "$work/pkcs11.cfg"
}
# apksigner's --provider-class instantiates the provider by reflection, which the module
# system refuses for SunPKCS11 on Java 9 and later. The JDK's own list of providers
# already names SunPKCS11, unconfigured; this points that same entry at the token, so
# the provider is installed by the JVM and apksigner only has to ask for a PKCS11 store.
java_security() {
conf="$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")/conf/security/java.security"
entry="$(grep -o '^security\.provider\.[0-9]*=SunPKCS11' "$conf" | cut -d= -f1)" ||
die "no SunPKCS11 entry in $conf"
printf '%s=SunPKCS11 %s\n' "$entry" "$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" <<EOF
openssl_conf = conf
[conf]
engines = engines
[engines]
pkcs11 = p11
[p11]
engine_id = pkcs11
MODULE_PATH = $module
PIN = $PKCS11_PIN
init = 0
[req]
distinguished_name = dn
[dn]
EOF
uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')"
# Thirty years: the certificate's dates are not checked for APK signatures, and
# replacing it would mean a new app identity on every device.
OPENSSL_CONF="$work/openssl.cnf" openssl req -new -x509 -sha256 -days 10950 \
-subj "$subject" -engine pkcs11 -keyform engine \
-key "pkcs11:id=${uri_id};type=private" -out "$work/cert.pem"
openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der"
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
--write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}"
cat "$work/cert.pem"
;;
list)
# shellcheck disable=SC2046 # java_store prints one argument per line
keytool -list -v -storepass:env PKCS11_PIN -keystore NONE $(java_store)
;;
*)
die "usage: hsm-sign detached IN OUT | apk IN OUT | make-cert SUBJECT | list"
;;
esac

88
signer/test.sh Executable file
View file

@ -0,0 +1,88 @@
#!/bin/sh
# The signer, end to end, against SoftHSM instead of the real HSM.
#
# test.sh detached signatures only
# test.sh UNSIGNED.apk [SIGNED.apk] and APK signing; the second keeps the signed APK
#
# Builds the signer image exactly as the action does, adds SoftHSM to it, and makes a
# token with an RSA key and no certificate, the state the real HSM's key is in. Then it
# makes a detached signature and checks it with openssl against the token's public key
# and, given an APK, runs make-cert and apk, and has apksigner verify the result.
# Everything but pcscd and the hardware is the code that runs in CI. Uses docker, or
# podman when there is no docker. Scratch space comes from mktemp, so TMPDIR moves it.
set -eu
usage() {
echo "usage: test.sh [UNSIGNED.apk [SIGNED.apk]]" >&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