| signer | ||
| action.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| LICENSE | ||
| README.md | ||
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 signatureSIG_n: SignatureURL_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 intoUHUB_ID(recommended): the hub'svid:pid, as shown bylsusb. 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 changeUHUB_LOCATION(optional): the hub's location (e.g.1-1.4, as shown bylsusb -torls /sys/bus/usb/devices). Takes precedence overUHUB_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 nowkey_id, default03filesis no longer required, as long asapkis 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