Gitea/GitHub action for signing with HSM over PKCS#11
Find a file
2026-10-04 07:22:50 -07:00
signer more robust - find USB hub on own, build docker image at runtime 2026-10-04 07:22:50 -07:00
action.yml more robust - find USB hub on own, build docker image at runtime 2026-10-04 07:22:50 -07:00
Dockerfile more robust - find USB hub on own, build docker image at runtime 2026-10-04 07:22:50 -07:00
entrypoint.sh more robust - find USB hub on own, build docker image at runtime 2026-10-04 07:22:50 -07:00
LICENSE action Dockerfile/entrypoint 2023-03-27 21:01:32 -07:00
README.md more robust - find USB hub on own, build docker image at runtime 2026-10-04 07:22:50 -07:00

Signs with an HSM

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
  • 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

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

      - name: Sign
        id: sign
        uses: https://git.lerch.org/lobo/action-hsm-sign@v3
        with:
          pin: ${{ secrets.HSM_USER_PIN }}
          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'

The APK must be zipaligned (AGP's release output already is).

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:

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'

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/) 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/ 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/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:

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 (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, 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 and a unique label, used as the runs-on attribute of the build. For example:

name: Sign

on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest-with-hsm

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:

runner:
  capacity: 1
  envs:
    UHUB_PORT: 4
    UHUB_ID: '366b:0004'

Updating the first example:

      - 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'
          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):

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/ 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