more robust - find USB hub on own, build docker image at runtime
This commit is contained in:
parent
83e6f9074c
commit
fd265f0023
7 changed files with 778 additions and 88 deletions
|
|
@ -5,8 +5,10 @@ FROM docker:28.3.1-dind-alpine3.22
|
||||||
|
|
||||||
# This is an alpine-based image
|
# This is an alpine-based image
|
||||||
|
|
||||||
|
# The HSM's power is switched through the kernel's sysfs port interface (see
|
||||||
|
# entrypoint.sh), so uhubctl is not needed
|
||||||
RUN true && \
|
RUN true && \
|
||||||
apk add --no-cache curl uhubctl && \
|
apk add --no-cache curl && \
|
||||||
apkArch="$(arch)" && \
|
apkArch="$(arch)" && \
|
||||||
if [ $apkArch = "x86_64" ]; then apkArch=amd64; fi && \
|
if [ $apkArch = "x86_64" ]; then apkArch=amd64; fi && \
|
||||||
curl -sLO https://github.com/sigstore/rekor/releases/download/v1.0.1/rekor-cli-linux-${apkArch} && \
|
curl -sLO https://github.com/sigstore/rekor/releases/download/v1.0.1/rekor-cli-linux-${apkArch} && \
|
||||||
|
|
@ -15,5 +17,8 @@ RUN true && \
|
||||||
true
|
true
|
||||||
|
|
||||||
COPY entrypoint.sh /
|
COPY entrypoint.sh /
|
||||||
|
# The signer's build context, built on the host daemon at run time so its
|
||||||
|
# layers are cached there between runs
|
||||||
|
COPY signer /signer
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
|
||||||
209
README.md
209
README.md
|
|
@ -1,42 +1,149 @@
|
||||||
Signs files using an HSM
|
Signs with an HSM
|
||||||
========================
|
================
|
||||||
|
|
||||||
Basic Usage
|
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
|
```yaml
|
||||||
- name: Sign
|
- name: Sign
|
||||||
id: sign
|
id: sign
|
||||||
uses: https://git.lerch.org/lobo/action-hsm-sign@v1
|
uses: https://git.lerch.org/lobo/action-hsm-sign@v3
|
||||||
with:
|
with:
|
||||||
pin: ${{ secrets.HSM_USER_PIN }}
|
pin: ${{ secrets.HSM_USER_PIN }}
|
||||||
files: ???
|
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'
|
public_key: 'https://emil.lerch.org/serverpublic.pem'
|
||||||
```
|
```
|
||||||
|
|
||||||
If a public key is specified, [rekor](https://github.com/sigstore/rekor) will
|
The APK must be zipaligned (AGP's release output already is).
|
||||||
be invoked, sending the signature to the [sigstore public transparency
|
|
||||||
log](https://sigstore.dev).
|
|
||||||
|
|
||||||
The action provides the following outputs:
|
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:
|
||||||
|
|
||||||
* Source: Source file used for the signature
|
```sh
|
||||||
* Signature: Signature
|
docker build -t hsm-signer signer
|
||||||
* URL: If a public key is specified, the URL output provides the sigstore log url
|
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'
|
||||||
|
```
|
||||||
|
|
||||||
Because multiple files can be signed, these outputs have numerical suffixes.
|
It prints the certificate; keep a copy. The certificate is the app's identity
|
||||||
In the above example, the output `${{ steps.sign.outputs.URL_1 }}` would be the
|
on every device from then on: replacing it means users uninstall and reinstall.
|
||||||
url for the first file signed with this action
|
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
|
Usage with Smart USB Hubs
|
||||||
-------------------------
|
-------------------------
|
||||||
|
|
||||||
Many consumer HSMs will "hang" after prolonged usage. To alleviate problems
|
Many consumer HSMs will "hang" after prolonged usage. To alleviate problems
|
||||||
associated with this, this action can integrate with smart USB hubs to turn
|
associated with this, this action can integrate with smart USB hubs (hubs with
|
||||||
on the hub's port and wait for the OS to recognize the attached HSM before
|
per-port power switching) to power cycle the hub's port and wait for the OS to
|
||||||
performing the signing action.
|
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**
|
**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
|
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
|
physical hardware, you will also need a runner set with a max concurrency of 1
|
||||||
|
|
@ -53,19 +160,69 @@ jobs:
|
||||||
runs-on: ubuntu-latest-with-hsm
|
runs-on: ubuntu-latest-with-hsm
|
||||||
```
|
```
|
||||||
|
|
||||||
The runner will also need to set environment variables `UHUB_PORT` and
|
The port is switched through the kernel's sysfs interface
|
||||||
`UHUB_LOCATION` as appropriate. To determine the proper values for these, it is
|
(`/sys/bus/usb/devices/<hub>:1.0/<hub>-port<N>/disable`), so the runner host
|
||||||
best to consult [uhubctl
|
needs Linux 6.0 or later, and the runner must run containers privileged
|
||||||
documentation](https://github.com/mvp/uhubctl?tab=readme-ov-file#usage) and run
|
(`container.privileged: true` in the runner config).
|
||||||
some command line tests. Updating the previous example:
|
|
||||||
|
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
|
```yaml
|
||||||
- name: Sign
|
- name: Sign
|
||||||
id: sign
|
id: sign
|
||||||
uses: https://git.lerch.org/lobo/action-hsm-sign@v1
|
uses: https://git.lerch.org/lobo/action-hsm-sign@v3
|
||||||
with:
|
with:
|
||||||
pin: ${{ secrets.HSM_USER_PIN }}
|
pin: ${{ secrets.HSM_USER_PIN }}
|
||||||
files: ???
|
files: dist/*
|
||||||
public_key: 'https://emil.lerch.org/serverpublic.pem'
|
public_key: 'https://emil.lerch.org/serverpublic.pem'
|
||||||
uhub_control: 'true'
|
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
|
||||||
|
|
|
||||||
32
action.yml
32
action.yml
|
|
@ -1,22 +1,36 @@
|
||||||
name: 'HSM Signing'
|
name: 'HSM Signing'
|
||||||
description: 'Signs using HSM'
|
description: 'Signs APKs and makes detached signatures of files, with a key held in an HSM'
|
||||||
author: 'lobo'
|
author: 'lobo'
|
||||||
inputs:
|
inputs:
|
||||||
files:
|
files:
|
||||||
description: 'Files to sign'
|
description: 'Files to make detached signatures of (a glob in one directory, or dir/** for all files under dir). Signatures are written beside them as FILE.sig'
|
||||||
required: true
|
required: false
|
||||||
user_pin:
|
apk:
|
||||||
|
description: 'An unsigned, zipaligned APK to sign (signature schemes v2 and v3). Signed before files, so files can include apk_output'
|
||||||
|
required: false
|
||||||
|
apk_output:
|
||||||
|
description: 'Where to write the signed APK. Required with apk'
|
||||||
|
required: false
|
||||||
|
pin:
|
||||||
description: 'User pin for HSM on build server'
|
description: 'User pin for HSM on build server'
|
||||||
required: true
|
required: true
|
||||||
slot:
|
key_id:
|
||||||
description: 'HSM slot used for signing'
|
description: 'CKA_ID of the key, in hex'
|
||||||
required: true
|
required: true
|
||||||
default: 3
|
default: '03'
|
||||||
|
slot_index:
|
||||||
|
description: 'PKCS#11 slot, by position in the slot list'
|
||||||
|
required: true
|
||||||
|
default: '0'
|
||||||
|
key_alias:
|
||||||
|
description: 'APK only: the key alias, when the token holds more than one key with a certificate'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
public_key:
|
public_key:
|
||||||
description: 'URL to PEM format public key. Specify only if uploading to sigstore'
|
description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore'
|
||||||
required: false
|
required: false
|
||||||
uhub_control:
|
uhub_control:
|
||||||
description: 'If HSM is attached to software controlled power hub, setting this to "true" will power on the HSM during operation'
|
description: 'If HSM is attached to software controlled power hub, setting this to "true" will power cycle the HSM before signing and power it off afterwards. The runner needs UHUB_PORT set (see README)'
|
||||||
required: true
|
required: true
|
||||||
default: "false"
|
default: "false"
|
||||||
runs:
|
runs:
|
||||||
|
|
|
||||||
363
entrypoint.sh
363
entrypoint.sh
|
|
@ -1,64 +1,328 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Sign with the HSM: an APK (input apk), detached signatures of files (input files),
|
||||||
|
# or both, APK first, so a files glob can include the signed APK. Builds the signer
|
||||||
|
# image (./signer) on the host's docker daemon, powers the HSM on if asked, and for
|
||||||
|
# each signature starts a signer container beside this one with the host's pcscd
|
||||||
|
# socket, copying the input in and the result out. Detached signatures can also be
|
||||||
|
# logged to sigstore.
|
||||||
|
#
|
||||||
# There is no concurrency control here. We are relying on the fact that
|
# There is no concurrency control here. We are relying on the fact that
|
||||||
# the runner on the host is set to a max capacity of 1
|
# the runner on the host is set to a max capacity of 1
|
||||||
if [ "${INPUT_UHUB_CONTROL}" != "false" ]; then
|
|
||||||
if [ -z "${UHUB_LOCATION}" ] || [ -z "${UHUB_PORT}" ]; then
|
# --- HSM power control -------------------------------------------------------
|
||||||
echo "error: UHUB control requested, but runner has not been configured with UHUB_LOCATION and UHUB_PORT environment variables"
|
#
|
||||||
exit 255
|
# The HSM is on port UHUB_PORT of a hub that can switch each port's power. The
|
||||||
fi
|
# kernel names the port by the hub's location (1-1.4 and the like), which
|
||||||
uhubctl -a off -p "${UHUB_PORT}" -l "${UHUB_LOCATION}" # Off seems to be reflected immediately
|
# changes when the hub is plugged in somewhere else, so it is found at run time:
|
||||||
# Capture the number of hidraw devices with the port off
|
#
|
||||||
# The way docker works, we can't seem to monitor /dev directory
|
# UHUB_LOCATION if set, used as given (checked to be a hub with that port)
|
||||||
# But a USB device should show up in dmesg log when this happens
|
# UHUB_ID else, if set: the hub with this vid:pid (as lsusb shows it)
|
||||||
#devs="$(find /dev -maxdepth 1 -name 'hi*' |wc -l)"
|
# neither the one hub with a smartcard on port UHUB_PORT or, failing
|
||||||
devs=$(dmesg |grep "usb ${UHUB_LOCATION}.${UHUB_PORT}" |grep -c "New USB device found")
|
# that, the one hub whose port UHUB_PORT has no power
|
||||||
uhubctl -a on -p "${UHUB_PORT}" -l "${UHUB_LOCATION}"
|
#
|
||||||
retries=0
|
# Docker gives a container a copy of /dev made when it starts, so devices coming
|
||||||
while [ "$(dmesg |grep "usb ${UHUB_LOCATION}.${UHUB_PORT}" |grep -c "New USB device found")" = "$devs" ] && [ $retries -lt 10 ]; do
|
# and going cannot be seen there. /sys/bus/usb is live, so that is where this
|
||||||
# Generally takes a few seconds to settle in
|
# watches the HSM go away and come back. Switching the power needs Linux 6.0 or
|
||||||
echo "waiting for device connection ($((retries+1)) / 10)"
|
# later and a privileged container, for /sys to be writable.
|
||||||
sleep 1
|
|
||||||
retries=$((retries+1))
|
uhub_sys=/sys/bus/usb/devices
|
||||||
|
uhub_location=""
|
||||||
|
uhub_port=""
|
||||||
|
uhub_switched=false
|
||||||
|
|
||||||
|
uhub_die() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The sysfs directory of port $2 of the hub at location $1. A root hub's
|
||||||
|
# location is its bus number: location 1 is device usb1, ports under 1-0:1.0.
|
||||||
|
uhub_port_dir() {
|
||||||
|
case "$1" in
|
||||||
|
*-*) echo "${uhub_sys}/$1:1.0/$1-port$2" ;;
|
||||||
|
*) echo "${uhub_sys}/$1-0:1.0/usb$1-port$2" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The location of a hub, from its device directory (1-1.4, usb1).
|
||||||
|
uhub_location_of_hub() {
|
||||||
|
_ul="$(basename "$1")"
|
||||||
|
echo "${_ul#usb}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The location of the hub a port directory belongs to.
|
||||||
|
uhub_location_of_port() {
|
||||||
|
_ul="$(basename "$1")"
|
||||||
|
_ul="${_ul%-port*}"
|
||||||
|
echo "${_ul#usb}"
|
||||||
|
}
|
||||||
|
|
||||||
|
uhub_is_hub() {
|
||||||
|
[ "$(cat "$1/bDeviceClass" 2>/dev/null)" = 09 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
uhub_attached() {
|
||||||
|
[ -e "$1/device" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
uhub_detached() {
|
||||||
|
! uhub_attached "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether the device on a port has a smartcard (CCID, class 0b) interface.
|
||||||
|
uhub_smartcard() {
|
||||||
|
for _uc in "$1"/device/*:*/bInterfaceClass; do
|
||||||
|
if [ "$(cat "$_uc" 2>/dev/null)" = 0b ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
if [ $retries -ge 10 ]; then
|
return 1
|
||||||
echo "device is not available. Aborting"
|
}
|
||||||
exit 1
|
|
||||||
|
# The kernel's view of the port's power (Linux 6.0 and later).
|
||||||
|
uhub_unpowered() {
|
||||||
|
[ "$(cat "$1/disable" 2>/dev/null)" = 1 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every hub, and what is on its port UHUB_PORT. For error messages.
|
||||||
|
uhub_list() {
|
||||||
|
echo "hubs (location vid:pid product: port ${UHUB_PORT}):"
|
||||||
|
for _ud in "${uhub_sys}"/*; do
|
||||||
|
if uhub_is_hub "$_ud"; then
|
||||||
|
_ul="$(uhub_location_of_hub "$_ud")"
|
||||||
|
_up="$(uhub_port_dir "$_ul" "${UHUB_PORT}")"
|
||||||
|
if [ ! -d "$_up" ]; then
|
||||||
|
_us="no such port"
|
||||||
|
elif uhub_smartcard "$_up"; then
|
||||||
|
_us="smartcard"
|
||||||
|
elif uhub_attached "$_up"; then
|
||||||
|
_us="another device"
|
||||||
|
elif uhub_unpowered "$_up"; then
|
||||||
|
_us="no power"
|
||||||
|
else
|
||||||
|
_us="nothing attached"
|
||||||
|
fi
|
||||||
|
echo " ${_ul} $(cat "$_ud/idVendor"):$(cat "$_ud/idProduct") $(cat "$_ud/product" 2>/dev/null): ${_us}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets uhub_location and uhub_port, as described at the top of this block.
|
||||||
|
uhub_find() {
|
||||||
|
_uhubs=""
|
||||||
|
if [ -n "${UHUB_LOCATION:-}" ]; then
|
||||||
|
_uby="UHUB_LOCATION=${UHUB_LOCATION}"
|
||||||
|
_uhubs="${UHUB_LOCATION}"
|
||||||
|
elif [ -n "${UHUB_ID:-}" ]; then
|
||||||
|
_uby="UHUB_ID=${UHUB_ID}"
|
||||||
|
_uid="$(echo "${UHUB_ID}" | tr 'A-F' 'a-f')"
|
||||||
|
for _ud in "${uhub_sys}"/*; do
|
||||||
|
if uhub_is_hub "$_ud" && [ "$(cat "$_ud/idVendor"):$(cat "$_ud/idProduct")" = "${_uid}" ]; then
|
||||||
|
_uhubs="${_uhubs} $(uhub_location_of_hub "$_ud")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
_uby="a smartcard on port ${UHUB_PORT}"
|
||||||
|
for _up in "${uhub_sys}"/*/*-port"${UHUB_PORT}"; do
|
||||||
|
if uhub_smartcard "$_up"; then
|
||||||
|
_uhubs="${_uhubs} $(uhub_location_of_port "$_up")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "${_uhubs}" ]; then
|
||||||
|
_uby="port ${UHUB_PORT} having no power"
|
||||||
|
for _up in "${uhub_sys}"/*/*-port"${UHUB_PORT}"; do
|
||||||
|
if uhub_detached "$_up" && uhub_unpowered "$_up"; then
|
||||||
|
_uhubs="${_uhubs} $(uhub_location_of_port "$_up")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2086 # one word per hub
|
||||||
|
set -- ${_uhubs}
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
uhub_list >&2
|
||||||
|
uhub_die "no hub found by ${_uby}"
|
||||||
|
fi
|
||||||
|
if [ $# -gt 1 ]; then
|
||||||
|
uhub_list >&2
|
||||||
|
uhub_die "more than one hub found by ${_uby} ($*): set UHUB_ID or UHUB_LOCATION on the runner"
|
||||||
|
fi
|
||||||
|
if [ ! -d "$(uhub_port_dir "$1" "${UHUB_PORT}")" ]; then
|
||||||
|
uhub_list >&2
|
||||||
|
uhub_die "$1 (from ${_uby}) is not a hub with a port ${UHUB_PORT}"
|
||||||
|
fi
|
||||||
|
uhub_location="$1"
|
||||||
|
uhub_port="$(uhub_port_dir "${uhub_location}" "${UHUB_PORT}")"
|
||||||
|
echo "HSM: port ${UHUB_PORT} of hub ${uhub_location}, found by ${_uby}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs "$@" once a second until it succeeds, giving up after 10 tries.
|
||||||
|
uhub_wait() {
|
||||||
|
_uw="$1"
|
||||||
|
shift
|
||||||
|
_ui=0
|
||||||
|
until "$@"; do
|
||||||
|
_ui=$((_ui + 1))
|
||||||
|
if [ "${_ui}" -gt 10 ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "waiting for ${_uw} (${_ui} / 10)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Switches the port off (1) or on (0) through the kernel's port "disable" file,
|
||||||
|
# which disconnects the HSM or has it enumerated as it does so. Not uhubctl:
|
||||||
|
# Alpine's is built without its sysfs support, so it only sends the hub the
|
||||||
|
# request, and the kernel never hears that the HSM went away.
|
||||||
|
uhub_power() {
|
||||||
|
echo "$1" >"${uhub_port}/disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
uhub_is_off() {
|
||||||
|
uhub_detached "${uhub_port}" && uhub_unpowered "${uhub_port}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Power the HSM off and on again, and wait for it to connect.
|
||||||
|
uhub_on() {
|
||||||
|
if [ -z "${UHUB_PORT:-}" ]; then
|
||||||
|
uhub_die "UHUB control requested, but the runner has no UHUB_PORT environment variable"
|
||||||
|
fi
|
||||||
|
uhub_find
|
||||||
|
# From here on the port is switched off again when the script exits.
|
||||||
|
uhub_switched=true
|
||||||
|
echo "HSM: switching the port off"
|
||||||
|
uhub_power 1 ||
|
||||||
|
uhub_die "could not write ${uhub_port}/disable: this needs Linux 6.0 or later and a privileged container"
|
||||||
|
# The kernel reads the power state back from the hub, so this also catches
|
||||||
|
# hubs that take the request and leave the power on.
|
||||||
|
uhub_wait "the port to switch off" uhub_is_off ||
|
||||||
|
uhub_die "port ${UHUB_PORT} of hub ${uhub_location} still has power or a device after switching it off"
|
||||||
|
# Long enough off for the HSM to reset.
|
||||||
|
sleep 1
|
||||||
|
echo "HSM: switching the port on"
|
||||||
|
uhub_power 0 ||
|
||||||
|
uhub_die "could not write ${uhub_port}/disable"
|
||||||
|
uhub_wait "the HSM to connect" uhub_smartcard "${uhub_port}" ||
|
||||||
|
uhub_die "no smartcard on port ${UHUB_PORT} of hub ${uhub_location} after switching it on"
|
||||||
|
echo "HSM: connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Power the HSM off, if uhub_on switched it. For the exit trap.
|
||||||
|
uhub_off() {
|
||||||
|
if [ "${uhub_switched}" = true ]; then
|
||||||
|
echo "HSM: switching the port off"
|
||||||
|
uhub_power 1 ||
|
||||||
|
echo "warning: could not switch off port ${UHUB_PORT} of hub ${uhub_location}" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# --- end HSM power control ---------------------------------------------------
|
||||||
|
|
||||||
|
container=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "${container}" ]; then
|
||||||
|
docker rm -f "${container}" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
# Whatever happened, so a failed run does not leave the HSM powered
|
||||||
|
uhub_off
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs `hsm-sign COMMAND` in a signer container. We can't use a volume mount
|
||||||
|
# because it will use the host volume, and we're not on the host, but in a
|
||||||
|
# container. So we create the container, copy the input in as /home/user/NAME_IN,
|
||||||
|
# run it, and copy /home/user/NAME_OUT back.
|
||||||
|
# usage: run_signer COMMAND IN OUT NAME_IN NAME_OUT
|
||||||
|
run_signer() {
|
||||||
|
container="$(docker create \
|
||||||
|
-v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
|
||||||
|
-e PKCS11_PIN -e PKCS11_SLOT_INDEX -e KEY_ID -e KEY_ALIAS \
|
||||||
|
"${signer}" "$1" "/home/user/$4" "/home/user/$5")" ||
|
||||||
|
die "could not create the signer container"
|
||||||
|
docker cp "$2" "${container}:/home/user/$4" || die "could not copy $2 into the signer"
|
||||||
|
# let container run, pick up the exit code. The exit trap removes the container
|
||||||
|
docker start -a "${container}" || exit $?
|
||||||
|
docker cp "${container}:/home/user/$5" "$3" || die "could not copy the result to $3"
|
||||||
|
docker rm "${container}" >/dev/null
|
||||||
|
container=""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pass these through sort so we can have deterministic output indexing
|
||||||
|
list_files() {
|
||||||
|
dir="$(dirname "${INPUT_FILES}")"
|
||||||
|
glob="$(basename "${INPUT_FILES}")"
|
||||||
|
if [ "${glob}" = "**" ]; then
|
||||||
|
find "$dir" -type f |sort
|
||||||
|
else
|
||||||
|
find "$dir" -maxdepth 1 -name "${glob}" -type f |sort
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${INPUT_FILES:-}" ] && [ -z "${INPUT_APK:-}" ]; then
|
||||||
|
die "nothing to sign: set files, apk, or both"
|
||||||
|
fi
|
||||||
|
if [ -n "${INPUT_APK:-}" ]; then
|
||||||
|
[ -n "${INPUT_APK_OUTPUT:-}" ] || die "apk is set, but apk_output is not"
|
||||||
|
[ -f "${INPUT_APK}" ] || die "no APK at ${INPUT_APK}"
|
||||||
|
elif [ -n "${INPUT_FILES:-}" ]; then
|
||||||
|
# With an APK to sign first, the glob is expanded after, to include it
|
||||||
|
all_files="$(list_files)"
|
||||||
|
[ -n "${all_files}" ] || die "no files match ${INPUT_FILES}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Before the HSM is powered, so it is not on through a cold build, and a
|
||||||
|
# failed build does not cycle it. The image is tagged with a hash of its build
|
||||||
|
# context and only built when that tag is missing: a build needs a buildkit
|
||||||
|
# session with the host daemon, which times out when the host is busy, so a
|
||||||
|
# run with an unchanged signer should not need one. (So the image is also not
|
||||||
|
# rebuilt for base image updates: remove it, and the next run builds afresh.)
|
||||||
|
signer="action-hsm-sign-signer:$(cd /signer && find . -type f -exec sha256sum {} + | sort -k 2 | sha256sum | cut -c1-12)"
|
||||||
|
if docker image inspect "${signer}" >/dev/null 2>&1; then
|
||||||
|
echo "Signer image ${signer} is already built"
|
||||||
|
else
|
||||||
|
echo "Building signer image ${signer}"
|
||||||
|
docker build -q -t "${signer}" /signer || die "could not build the signer image"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The PIN reaches the signer as an environment variable, by name: never an
|
||||||
|
# argument
|
||||||
|
export PKCS11_PIN="${INPUT_PIN:-}"
|
||||||
|
export PKCS11_SLOT_INDEX="${INPUT_SLOT_INDEX:-0}"
|
||||||
|
export KEY_ID="${INPUT_KEY_ID:-03}"
|
||||||
|
export KEY_ALIAS="${INPUT_KEY_ALIAS:-}"
|
||||||
|
|
||||||
|
if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then
|
||||||
|
uhub_on
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${INPUT_APK:-}" ]; then
|
||||||
|
echo "Signing APK ${INPUT_APK}. Signed APK destination: ${INPUT_APK_OUTPUT}"
|
||||||
|
mkdir -p "$(dirname "${INPUT_APK_OUTPUT}")"
|
||||||
|
run_signer apk "${INPUT_APK}" "${INPUT_APK_OUTPUT}" in.apk out.apk
|
||||||
|
if [ -n "${INPUT_FILES:-}" ]; then
|
||||||
|
all_files="$(list_files)"
|
||||||
|
[ -n "${all_files}" ] || die "no files match ${INPUT_FILES}"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
dir="$(dirname "${INPUT_FILES}")"
|
if [ -z "${INPUT_FILES:-}" ]; then
|
||||||
glob="$(basename "${INPUT_FILES}")"
|
exit 0
|
||||||
# Pass these through sort so we can have deterministic output indexing
|
|
||||||
if [ "${glob}" = "**" ]; then
|
|
||||||
all_files="$(find "$dir" -type f |sort)"
|
|
||||||
else
|
|
||||||
all_files="$(find "$dir" -maxdepth 1 -name "${glob}" -type f |sort)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
i=0
|
i=0
|
||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
sign_dir="$(dirname "$f")"
|
sign_dir="$(dirname "$f")"
|
||||||
sign_file="$(basename "$f")"
|
sign_file="$(basename "$f")"
|
||||||
dest_sig="${sign_dir}/${sign_file}.sig"
|
dest_sig="${sign_dir}/${sign_file}.sig"
|
||||||
echo "Signing file $f. Signature file destination: ${dest_sig}"
|
echo "Signing file $f. Signature file destination: ${dest_sig}"
|
||||||
# We can't use a volume mount because it will use the host volume, and we're
|
run_signer detached "$f" "${dest_sig}" artifact signature
|
||||||
# not on the host, but in a container. So we'll create a container, copy
|
|
||||||
# the file to sign in place, get the signature and copy that back
|
|
||||||
container="$(docker create \
|
|
||||||
-v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
|
|
||||||
-e INPUT_PIN \
|
|
||||||
git.lerch.org/lobo/pkcs11:1 \
|
|
||||||
-s --id "${INPUT_SLOT}" -m SHA256-RSA-PKCS -i artifact -o signature --pin env:INPUT_PIN)"
|
|
||||||
docker cp "$f" "${container}":/home/user/artifact
|
|
||||||
docker start -a "$container" # let container run, pick up the exit code
|
|
||||||
ec=$?
|
|
||||||
if [ $ec -ne 0 ]; then
|
|
||||||
docker rm "$container"
|
|
||||||
exit $ec
|
|
||||||
fi
|
|
||||||
# We are clear. Copy signature back into the workspace and remove the container
|
|
||||||
docker cp "${container}":/home/user/signature "${dest_sig}"
|
|
||||||
docker rm "${container}"
|
|
||||||
if [ -n "${INPUT_PUBLIC_KEY}" ]; then
|
if [ -n "${INPUT_PUBLIC_KEY}" ]; then
|
||||||
echo "Public key url specified. Uploading to sigstore public transparency log"
|
echo "Public key url specified. Uploading to sigstore public transparency log"
|
||||||
echo "Fetching key from ${INPUT_PUBLIC_KEY}"
|
echo "Fetching key from ${INPUT_PUBLIC_KEY}"
|
||||||
|
|
@ -77,8 +341,3 @@ while IFS= read -r f; do
|
||||||
done <<ALLFILES_INPUT
|
done <<ALLFILES_INPUT
|
||||||
$all_files
|
$all_files
|
||||||
ALLFILES_INPUT
|
ALLFILES_INPUT
|
||||||
|
|
||||||
if [ "${INPUT_UHUB_CONTROL}" != "false" ]; then
|
|
||||||
# Turn off the port when we're done
|
|
||||||
uhubctl -a off -p "${UHUB_PORT}" -l "${UHUB_LOCATION}"
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
37
signer/Dockerfile
Normal file
37
signer/Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# The signer: the container that talks to the HSM, started beside the action's own
|
||||||
|
# container by entrypoint.sh. See hsm-sign.sh for what it does.
|
||||||
|
#
|
||||||
|
# Debian with opensc, reaching the HSM through the host's pcscd socket (mounted at run
|
||||||
|
# time) as an unprivileged uid 1000. Plus Debian's apksigner, which signs APKs through
|
||||||
|
# Java's SunPKCS11 provider and so needs nothing but opensc's PKCS#11 module, and
|
||||||
|
# OpenSSL's PKCS#11 engine, used once to make the certificate an APK signature has to
|
||||||
|
# carry (hsm-sign.sh, make-cert).
|
||||||
|
#
|
||||||
|
# The action builds this on the host's docker daemon at run time, so the image always
|
||||||
|
# matches the action's version, and the layers are cached there between runs. opensc
|
||||||
|
# and pcsc-lite have to speak the host pcscd's socket protocol, so this follows the
|
||||||
|
# host's Debian release (nas2: bookworm). That is the one thing here test.sh cannot show:
|
||||||
|
# it uses SoftHSM, with no pcscd.
|
||||||
|
#
|
||||||
|
# BASE is an argument so the image can follow the host if it moves.
|
||||||
|
ARG BASE=debian:bookworm
|
||||||
|
FROM ${BASE}
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
opensc openjdk-17-jre-headless libapksig-java openssl libengine-pkcs11-openssl ca-certificates && \
|
||||||
|
# Debian's apksigner package depends on the desktop JRE (default-jre: AWT, GTK, Mesa,
|
||||||
|
# X11, icon themes - 115 packages instead of 42) though it is a command-line tool that
|
||||||
|
# runs on the headless one. So its files are unpacked rather than the package
|
||||||
|
# installed: one jar and its wrapper script, and the headless JRE and libapksig it
|
||||||
|
# needs are installed above. dpkg's database never hears of it, so there is no
|
||||||
|
# broken dependency left for a later apt-get to trip on.
|
||||||
|
cd /tmp && apt-get download apksigner && dpkg-deb -x apksigner_*.deb / && rm apksigner_*.deb && \
|
||||||
|
useradd -m -u 1000 user && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY hsm-sign.sh /usr/local/bin/hsm-sign
|
||||||
|
|
||||||
|
USER user
|
||||||
|
WORKDIR /home/user
|
||||||
|
ENTRYPOINT ["/usr/local/bin/hsm-sign"]
|
||||||
130
signer/hsm-sign.sh
Executable file
130
signer/hsm-sign.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Sign with a key held in an HSM, through PKCS#11.
|
||||||
|
#
|
||||||
|
# hsm-sign detached IN OUT a detached signature of IN (RSA PKCS#1 v1.5 over
|
||||||
|
# SHA-256), written to OUT
|
||||||
|
# hsm-sign apk IN.apk OUT.apk sign an APK (signature schemes v2 and v3), then verify
|
||||||
|
# hsm-sign make-cert SUBJECT one-time setup for apk: a self-signed certificate for
|
||||||
|
# the key, written to the token beside it, and printed
|
||||||
|
# as PEM
|
||||||
|
# hsm-sign list what Java sees on the token, for diagnosing an alias
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PKCS11_PIN the user PIN (required)
|
||||||
|
# PKCS11_MODULE the PKCS#11 library; default: opensc's
|
||||||
|
# PKCS11_SLOT_INDEX which slot, by position in the slot list; default 0
|
||||||
|
# KEY_ID the key's CKA_ID in hex; default 03
|
||||||
|
# KEY_LABEL the label make-cert gives the certificate; default apk-cert
|
||||||
|
# KEY_ALIAS which key apksigner uses, when the token holds more than one
|
||||||
|
#
|
||||||
|
# Why apk needs a certificate on the token: an APK signature embeds the signer's
|
||||||
|
# certificate, and Java's PKCS#11 key store only offers a private key that has a
|
||||||
|
# certificate with the same CKA_ID. A key used only for detached signatures has none
|
||||||
|
# until make-cert writes one. Android pins an app to its signing certificate for good,
|
||||||
|
# so make-cert runs once and its certificate is kept.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "hsm-sign: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -n "${PKCS11_PIN:-}" ] || die "PKCS11_PIN is not set"
|
||||||
|
module="${PKCS11_MODULE:-$(find /usr/lib -name opensc-pkcs11.so | head -n 1)}"
|
||||||
|
[ -f "$module" ] || die "no PKCS#11 module at '$module'"
|
||||||
|
slot_index="${PKCS11_SLOT_INDEX:-0}"
|
||||||
|
key_id="${KEY_ID:-03}"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
chmod 700 "$work"
|
||||||
|
|
||||||
|
# SunPKCS11's configuration: which library, which slot.
|
||||||
|
cat > "$work/pkcs11.cfg" <<EOF
|
||||||
|
name = HSM
|
||||||
|
library = $module
|
||||||
|
slotListIndex = $slot_index
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# What apksigner and keytool need to reach the token through Java. The PIN goes by
|
||||||
|
# environment variable name, never on a command line.
|
||||||
|
java_store() {
|
||||||
|
printf '%s\n' -storetype PKCS11 -providerClass sun.security.pkcs11.SunPKCS11 \
|
||||||
|
-providerArg "$work/pkcs11.cfg"
|
||||||
|
}
|
||||||
|
|
||||||
|
# apksigner's --provider-class instantiates the provider by reflection, which the module
|
||||||
|
# system refuses for SunPKCS11 on Java 9 and later. The JDK's own list of providers
|
||||||
|
# already names SunPKCS11, unconfigured; this points that same entry at the token, so
|
||||||
|
# the provider is installed by the JVM and apksigner only has to ask for a PKCS11 store.
|
||||||
|
java_security() {
|
||||||
|
conf="$(dirname "$(dirname "$(readlink -f "$(command -v java)")")")/conf/security/java.security"
|
||||||
|
entry="$(grep -o '^security\.provider\.[0-9]*=SunPKCS11' "$conf" | cut -d= -f1)" ||
|
||||||
|
die "no SunPKCS11 entry in $conf"
|
||||||
|
printf '%s=SunPKCS11 %s\n' "$entry" "$work/pkcs11.cfg" > "$work/java.security"
|
||||||
|
echo "-Djava.security.properties=$work/java.security"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
detached)
|
||||||
|
[ $# -eq 3 ] || die "usage: hsm-sign detached IN OUT"
|
||||||
|
# The signature action-hsm-sign has always made, so signatures verify (and match
|
||||||
|
# sigstore entries) the same across versions: PKCS#1 v1.5 is deterministic.
|
||||||
|
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
|
||||||
|
--sign --id "$key_id" --mechanism SHA256-RSA-PKCS --input-file "$2" --output-file "$3"
|
||||||
|
;;
|
||||||
|
|
||||||
|
apk)
|
||||||
|
[ $# -eq 3 ] || die "usage: hsm-sign apk IN.apk OUT.apk"
|
||||||
|
set -- "$2" "$3"
|
||||||
|
alias_args=""
|
||||||
|
[ -n "${KEY_ALIAS:-}" ] && alias_args="--ks-key-alias ${KEY_ALIAS}"
|
||||||
|
# shellcheck disable=SC2086 # alias_args is two words or none, by construction
|
||||||
|
JAVA_TOOL_OPTIONS="$(java_security)" apksigner sign \
|
||||||
|
--ks NONE --ks-type PKCS11 --ks-pass env:PKCS11_PIN \
|
||||||
|
$alias_args \
|
||||||
|
--in "$1" --out "$2"
|
||||||
|
# Proof, in the log, of what was signed and by whom.
|
||||||
|
apksigner verify --verbose --print-certs "$2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
make-cert)
|
||||||
|
[ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'"
|
||||||
|
subject="$2"
|
||||||
|
# OpenSSL's PKCS#11 engine, pointed at the same module, signs the certificate with
|
||||||
|
# the HSM's key. The PIN is in a file only this process can read, not an argument.
|
||||||
|
cat > "$work/openssl.cnf" <<EOF
|
||||||
|
openssl_conf = conf
|
||||||
|
[conf]
|
||||||
|
engines = engines
|
||||||
|
[engines]
|
||||||
|
pkcs11 = p11
|
||||||
|
[p11]
|
||||||
|
engine_id = pkcs11
|
||||||
|
MODULE_PATH = $module
|
||||||
|
PIN = $PKCS11_PIN
|
||||||
|
init = 0
|
||||||
|
[req]
|
||||||
|
distinguished_name = dn
|
||||||
|
[dn]
|
||||||
|
EOF
|
||||||
|
uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')"
|
||||||
|
# Thirty years: the certificate's dates are not checked for APK signatures, and
|
||||||
|
# replacing it would mean a new app identity on every device.
|
||||||
|
OPENSSL_CONF="$work/openssl.cnf" openssl req -new -x509 -sha256 -days 10950 \
|
||||||
|
-subj "$subject" -engine pkcs11 -keyform engine \
|
||||||
|
-key "pkcs11:id=${uri_id};type=private" -out "$work/cert.pem"
|
||||||
|
openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der"
|
||||||
|
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
|
||||||
|
--write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}"
|
||||||
|
cat "$work/cert.pem"
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
# shellcheck disable=SC2046 # java_store prints one argument per line
|
||||||
|
keytool -list -v -storepass:env PKCS11_PIN -keystore NONE $(java_store)
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
die "usage: hsm-sign detached IN OUT | apk IN OUT | make-cert SUBJECT | list"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
88
signer/test.sh
Executable file
88
signer/test.sh
Executable file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# The signer, end to end, against SoftHSM instead of the real HSM.
|
||||||
|
#
|
||||||
|
# test.sh detached signatures only
|
||||||
|
# test.sh UNSIGNED.apk [SIGNED.apk] and APK signing; the second keeps the signed APK
|
||||||
|
#
|
||||||
|
# Builds the signer image exactly as the action does, adds SoftHSM to it, and makes a
|
||||||
|
# token with an RSA key and no certificate, the state the real HSM's key is in. Then it
|
||||||
|
# makes a detached signature and checks it with openssl against the token's public key
|
||||||
|
# and, given an APK, runs make-cert and apk, and has apksigner verify the result.
|
||||||
|
# Everything but pcscd and the hardware is the code that runs in CI. Uses docker, or
|
||||||
|
# podman when there is no docker. Scratch space comes from mktemp, so TMPDIR moves it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: test.sh [UNSIGNED.apk [SIGNED.apk]]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[ $# -le 2 ] || usage
|
||||||
|
apk_in=""
|
||||||
|
apk_out="${2:-}"
|
||||||
|
if [ $# -ge 1 ]; then
|
||||||
|
[ -f "$1" ] || usage
|
||||||
|
apk_in="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||||
|
fi
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
engine="$(command -v docker || command -v podman)" || { echo "need docker or podman" >&2; exit 2; }
|
||||||
|
|
||||||
|
"$engine" build -q -t action-hsm-sign-signer:test "$here" >/dev/null
|
||||||
|
"$engine" build -q -t action-hsm-sign-signer:softhsm - >/dev/null <<'EOF'
|
||||||
|
FROM action-hsm-sign-signer:test
|
||||||
|
USER root
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends softhsm2 && rm -rf /var/lib/apt/lists/*
|
||||||
|
USER user
|
||||||
|
EOF
|
||||||
|
|
||||||
|
out_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$out_dir"' EXIT
|
||||||
|
chmod 777 "$out_dir"
|
||||||
|
|
||||||
|
set -- -v "$out_dir:/out"
|
||||||
|
if [ -n "$apk_in" ]; then
|
||||||
|
set -- "$@" -v "$apk_in:/home/user/in.apk:ro"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2016 # expanded inside the container
|
||||||
|
"$engine" run --rm --entrypoint /bin/sh "$@" action-hsm-sign-signer:softhsm -euc '
|
||||||
|
export SOFTHSM2_CONF="$HOME/softhsm2.conf"
|
||||||
|
mkdir -p "$HOME/tokens"
|
||||||
|
echo "directories.tokendir = $HOME/tokens" > "$SOFTHSM2_CONF"
|
||||||
|
export PKCS11_MODULE="$(find /usr/lib -name libsofthsm2.so | head -n 1)"
|
||||||
|
export PKCS11_PIN=123456 KEY_ID=03
|
||||||
|
softhsm2-util --init-token --free --label hsm-sign-test --pin "$PKCS11_PIN" --so-pin 87654321 >/dev/null
|
||||||
|
# A key and nothing else, as on the real HSM before make-cert.
|
||||||
|
pkcs11-tool --module "$PKCS11_MODULE" --login --pin env:PKCS11_PIN \
|
||||||
|
--keypairgen --key-type rsa:4096 --id "$KEY_ID" --label test-key >/dev/null
|
||||||
|
pkcs11-tool --module "$PKCS11_MODULE" --read-object --type pubkey --id "$KEY_ID" |
|
||||||
|
openssl pkey -pubin -inform DER -out "$HOME/pub.pem"
|
||||||
|
want="$(openssl pkey -pubin -in "$HOME/pub.pem" -outform DER | sha256sum | cut -d" " -f1)"
|
||||||
|
|
||||||
|
echo "== detached"
|
||||||
|
echo "an artifact" > artifact
|
||||||
|
hsm-sign detached artifact /out/artifact.sig
|
||||||
|
# What rekor and users do with the published public key.
|
||||||
|
openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact
|
||||||
|
|
||||||
|
[ -f in.apk ] || exit 0
|
||||||
|
echo "== make-cert"
|
||||||
|
hsm-sign make-cert "/CN=hsm-sign test signer" > /out/cert.pem
|
||||||
|
openssl x509 -in /out/cert.pem -noout -subject -fingerprint -sha256
|
||||||
|
echo "== apk"
|
||||||
|
hsm-sign apk in.apk /out/signed.apk
|
||||||
|
echo "== independent verify"
|
||||||
|
apksigner verify --verbose /out/signed.apk
|
||||||
|
# The APK is signed by the key, not merely by a certificate naming it: the public key
|
||||||
|
# apksigner reports is the token'"'"'s.
|
||||||
|
got="$(apksigner verify --verbose --print-certs /out/signed.apk | sed -n "s/^Signer #1 public key SHA-256 digest: //p")"
|
||||||
|
echo "token public key sha256: $want"
|
||||||
|
echo "APK signer key sha256: $got"
|
||||||
|
[ "$want" = "$got" ]
|
||||||
|
'
|
||||||
|
echo "test.sh: detached signature verified"
|
||||||
|
if [ -n "$apk_in" ]; then
|
||||||
|
if [ -n "$apk_out" ]; then
|
||||||
|
cp "$out_dir/signed.apk" "$apk_out"
|
||||||
|
fi
|
||||||
|
echo "test.sh: signed and verified $(basename "$apk_in")"
|
||||||
|
fi
|
||||||
Loading…
Add table
Reference in a new issue