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](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@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: ```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' ``` 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 (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: ```yaml 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/: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@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): ```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