228 lines
9.2 KiB
Markdown
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
|