action-hsm-sign/README.md

228 lines
9.2 KiB
Markdown

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