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 # The app's signing certificate (see below): any other fails the step apk_cert_sha256: '' # 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. Whatever certificate the token holds for the key is used, and a token holds one per key, so **every APK signed with a key carries the same certificate**: one signing identity for all of them, which is the usual arrangement for an Android developer. Give it a generic subject (you or your organization, not an app), and use a separate key if an app ever needs an identity of its own. The certificate is public, so it is no secret where it is made; what matters is that it is chosen once: it is the identity of every app signed with it, on every device, and an APK signed with a different certificate is refused as an update (users uninstall and reinstall). To give the key a certificate, on the HSM host, with the HSM powered, from a clone of this repository. The certificate is assembled in the container and signed on the token with the user PIN, so the key never leaves it. Writing it to an OpenPGP card (such as the Nitrokey Pro) takes the card's Admin PIN as well: give it as `ADMIN_PIN`, and `make-cert` writes the certificate with OpenSC's `pkcs15-init`, as Nitrokey documents for those cards. Without `ADMIN_PIN`, the user PIN writes it over PKCS#11 (SoftHSM and similar tokens). ```sh docker build -t hsm-signer signer read -rs PKCS11_PIN && export PKCS11_PIN read -rs ADMIN_PIN && export ADMIN_PIN # Each PIN tried once: a wrong one costs a try, and a card locks a PIN after three docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ -e PKCS11_PIN -e ADMIN_PIN hsm-signer check-pin docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ -e PKCS11_PIN -e ADMIN_PIN hsm-signer make-cert '/CN=Your Name/O=example.org' ``` `check-pin` and `make-cert` refuse to try a PIN that is on its last try. A correct PIN resets the count, so a normal signing run (with the PIN CI uses) gets the tries back. If the key already has a certificate, `make-cert` shows it and stops. Add `-e REPLACE_CERT=1` to replace it (keep the PEM it showed, if anything else uses that certificate). The new certificate is read back from the token to check it, and printed as PEM, with its `sha256 (apk_cert_sha256)`. Keep the PEM (it is public: publish it beside the public key, or keep it in each app's repository) and put the sha256 in each app's workflow as `apk_cert_sha256`, so a certificate replaced on the token later fails the build instead of silently changing the apps' identity. Its 30-year validity does not matter for APKs, whose certificate dates Android does not check, but is long enough for Google Play, which wants validity past 2033-10-22. 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 | | `apk_cert_sha256` | | APK only: the certificate the APK must be signed with; any other fails | | `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` (including its refusal to replace a certificate, and a replacement), APK signing pinned to the current certificate (and refused when pinned to the replaced one), `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