From e4d31848fd36eddfb9f906653783f722b4f93a7c Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 09:53:28 -0700 Subject: [PATCH] add certificate pinning capabilities --- README.md | 68 ++++++++++++++++++++++----------- action.yml | 4 ++ entrypoint.sh | 3 +- signer/hsm-sign.sh | 93 +++++++++++++++++++++++++++++++++++++++++----- signer/test.sh | 37 ++++++++++++++++-- 5 files changed, 168 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6fafc2a..272a7a7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ APK signing 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/* @@ -63,38 +65,58 @@ APK signing 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: +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, so the key never leaves it): ```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' + -e PKCS11_PIN hsm-signer make-cert '/CN=Your Name/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. +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. Running the same container with `list` instead of `make-cert` +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 | -| `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) | +| 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. @@ -122,9 +144,11 @@ 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: +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 diff --git a/action.yml b/action.yml index dcec03e..f481e29 100644 --- a/action.yml +++ b/action.yml @@ -26,6 +26,10 @@ inputs: description: 'APK only: the key alias, when the token holds more than one key with a certificate' required: false default: '' + apk_cert_sha256: + description: 'APK only: SHA-256 of the certificate the APK must be signed with (the app identity). Signing with any other certificate fails the step. Recommended once the certificate is chosen' + required: false + default: '' public_key: description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore' required: false diff --git a/entrypoint.sh b/entrypoint.sh index 6529925..972747a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -243,7 +243,7 @@ die() { 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 \ + -e PKCS11_PIN -e PKCS11_SLOT_INDEX -e KEY_ID -e KEY_ALIAS -e APK_CERT_SHA256 \ "${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" @@ -297,6 +297,7 @@ 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:-}" +export APK_CERT_SHA256="${INPUT_APK_CERT_SHA256:-}" if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then uhub_on diff --git a/signer/hsm-sign.sh b/signer/hsm-sign.sh index b8db52c..5c69961 100755 --- a/signer/hsm-sign.sh +++ b/signer/hsm-sign.sh @@ -4,9 +4,10 @@ # 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 make-cert SUBJECT setup for apk: a self-signed certificate for the key, +# signed on the token, written to it beside the key, +# read back, and printed as PEM. Refuses if the key +# already has a certificate, unless REPLACE_CERT=1 # hsm-sign list what Java sees on the token, for diagnosing an alias # # Environment: @@ -16,12 +17,15 @@ # 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 +# APK_CERT_SHA256 apk: the certificate the APK must be signed with (the SHA-256 of +# its DER, as make-cert and apk print it); any other is an error +# REPLACE_CERT make-cert: 1 to replace an existing certificate # # 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. +# certificate with the same CKA_ID. Whatever certificate is there is used, whether +# make-cert wrote it or not. Android pins an app to its signing certificate for good, +# so it is chosen once and kept (and pinned with APK_CERT_SHA256). set -eu die() { @@ -33,11 +37,33 @@ die() { 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}" +# Lower case and an even number of digits, as pkcs11-tool lists ids and as the pkcs11: +# URI below needs them. +key_id="$(printf '%s' "${KEY_ID:-03}" | tr 'A-F' 'a-f')" +[ $((${#key_id} % 2)) -eq 0 ] || key_id="0${key_id}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT chmod 700 "$work" +# The certificate for the key, in DER, to $1; fails if there is none. Certificates are +# public objects: no login. +read_cert() { + pkcs11-tool --module "$module" --slot-index "$slot_index" \ + --read-object --type cert --id "$key_id" --output-file "$1" >/dev/null 2>&1 && [ -s "$1" ] +} + +# How many certificates the token holds for the key. +cert_count() { + pkcs11-tool --module "$module" --slot-index "$slot_index" --list-objects --type cert 2>/dev/null | + grep -cE "^ *ID: *${key_id}\$" || true +} + +# A certificate (DER, $1) as a person and as apk_cert_sha256 want to see it. +describe_cert() { + openssl x509 -inform DER -in "$1" -noout -subject -startdate -enddate + echo "sha256 (apk_cert_sha256): $(sha256sum "$1" | cut -d' ' -f1)" +} + # SunPKCS11's configuration: which library, which slot. cat > "$work/pkcs11.cfg" <&2 + describe_cert "$work/old.der" >&2 + openssl x509 -inform DER -in "$work/old.der" >&2 + [ "${REPLACE_CERT:-}" = 1 ] || + die "not replacing it. Set REPLACE_CERT=1 to replace it (keep the PEM above if anything uses it)" + echo "hsm-sign: REPLACE_CERT=1: replacing it" >&2 + replacing=1 + else + replacing=0 + fi # 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" <&2 || + echo "hsm-sign: could not delete the old certificate; writing over it" >&2 + fi 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}" + --write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}" >&2 + # Read back: exactly one certificate for the key, and it is the new one. + count="$(cert_count)" + [ "$count" = 1 ] || die "the token has ${count} certificates for key ${key_id} after writing, expected 1" + read_cert "$work/check.der" || die "could not read the certificate back" + cmp -s "$work/cert.der" "$work/check.der" || + die "the certificate read back is not the one written" + echo "hsm-sign: written and read back:" >&2 + describe_cert "$work/cert.der" >&2 cat "$work/cert.pem" ;; diff --git a/signer/test.sh b/signer/test.sh index f25137a..66be591 100755 --- a/signer/test.sh +++ b/signer/test.sh @@ -65,19 +65,48 @@ fi openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact [ -f in.apk ] || exit 0 + certsha() { openssl x509 -in "$1" -outform DER | sha256sum | cut -d" " -f1; } + certs() { pkcs11-tool --module "$PKCS11_MODULE" --list-objects --type cert 2>/dev/null | grep -cE "^ *ID: *$KEY_ID\$" || true; } + 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 + first="$(certsha /out/cert.pem)" + echo "== make-cert again, without REPLACE_CERT: must refuse" + if hsm-sign make-cert "/CN=another signer" > /out/refused.pem 2> /out/refused.err; then + echo "make-cert replaced a certificate without REPLACE_CERT=1"; exit 1 + fi + grep -q "already has a certificate" /out/refused.err + [ "$(certs)" = 1 ] + echo "refused, and the token still has one certificate" + echo "== make-cert with REPLACE_CERT=1" + REPLACE_CERT=1 hsm-sign make-cert "/CN=hsm-sign test signer 2" > /out/cert2.pem + second="$(certsha /out/cert2.pem)" + [ "$first" != "$second" ] + [ "$(certs)" = 1 ] + echo "replaced, and the token has one certificate: $second" + + echo "== apk, pinned to the current certificate" + APK_CERT_SHA256="$second" hsm-sign apk in.apk /out/signed.apk + echo "== apk, pinned to the replaced certificate: must fail" + if APK_CERT_SHA256="$first" hsm-sign apk in.apk /out/wrong.apk > /out/wrong.log 2>&1; then + echo "apk signed with a certificate other than the pinned one"; exit 1 + fi + grep -q "not the expected" /out/wrong.log + [ ! -e /out/wrong.apk ] + echo "refused, and no APK left behind" 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")" + # apksigner reports is the token'"'"'s, and the certificate is the current one. + printed="$(apksigner verify --verbose --print-certs /out/signed.apk)" + got="$(echo "$printed" | sed -n "s/^Signer #1 public key SHA-256 digest: //p")" + gotcert="$(echo "$printed" | sed -n "s/^Signer #1 certificate SHA-256 digest: //p")" echo "token public key sha256: $want" echo "APK signer key sha256: $got" + echo "APK certificate sha256: $gotcert" [ "$want" = "$got" ] + [ "$gotcert" = "$second" ] ' echo "test.sh: detached signature verified" if [ -n "$apk_in" ]; then