add certificate pinning capabilities

This commit is contained in:
Emil Lerch 2026-10-04 09:53:28 -07:00
parent fd265f0023
commit e4d31848fd
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 168 additions and 37 deletions

View file

@ -54,6 +54,8 @@ APK signing
pin: ${{ secrets.HSM_USER_PIN }} pin: ${{ secrets.HSM_USER_PIN }}
apk: unsigned/app-release-unsigned.apk apk: unsigned/app-release-unsigned.apk
apk_output: dist/app.apk apk_output: dist/app.apk
# The app's signing certificate (see below): any other fails the step
apk_cert_sha256: '<sha256 printed by make-cert>'
# Optional: detached signatures of everything in dist/, the signed APK # Optional: detached signatures of everything in dist/, the signed APK
# included # included
files: dist/* files: dist/*
@ -63,38 +65,58 @@ APK signing
The APK must be zipaligned (AGP's release output already is). The APK must be zipaligned (AGP's release output already is).
An APK signature carries a certificate, and Java (which `apksigner` runs on) 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 only offers a PKCS#11 key that has a certificate on the token. Whatever
the first APK is signed**, make a self-signed certificate for the key and store certificate the token holds for the key is used, and a token holds one per key,
it beside the key. On the HSM host, with the HSM powered, from a clone of this so **every APK signed with a key carries the same certificate**: one signing
repository: 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 ```sh
docker build -t hsm-signer signer docker build -t hsm-signer signer
read -rs PKCS11_PIN && export PKCS11_PIN read -rs PKCS11_PIN && export PKCS11_PIN
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ 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 If the key already has a certificate, `make-cert` shows it and stops. Add
on every device from then on: replacing it means users uninstall and reinstall. `-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 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. shows what Java sees on the token.
Inputs Inputs
------ ------
| input | default | | | input | default | |
|----------------|---------|--------------------------------------------------------------------------| |-------------------|---------|--------------------------------------------------------------------------|
| `files` | | files to make detached signatures of | | `files` | | files to make detached signatures of |
| `apk` | | unsigned APK to sign | | `apk` | | unsigned APK to sign |
| `apk_output` | | where to write the signed APK; required with `apk` | | `apk_output` | | where to write the signed APK; required with `apk` |
| `pin` | | the HSM's user PIN (required) | | `pin` | | the HSM's user PIN (required) |
| `key_id` | `03` | the key's CKA_ID, in hex | | `key_id` | `03` | the key's CKA_ID, in hex |
| `slot_index` | `0` | PKCS#11 slot, by position in the slot list | | `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 | | `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 | | `apk_cert_sha256` | | APK only: the certificate the APK must be signed with; any other fails |
| `uhub_control` | `false` | power cycle the HSM on a smart USB hub (below) | | `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. 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, [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 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`, 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 (including its refusal to replace a certificate, and a replacement), APK
token's: 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 ```sh
signer/test.sh # detached only signer/test.sh # detached only

View file

@ -26,6 +26,10 @@ inputs:
description: 'APK only: the key alias, when the token holds more than one key with a certificate' description: 'APK only: the key alias, when the token holds more than one key with a certificate'
required: false required: false
default: '' 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: public_key:
description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore' description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore'
required: false required: false

View file

@ -243,7 +243,7 @@ die() {
run_signer() { run_signer() {
container="$(docker create \ container="$(docker create \
-v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \ -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")" || "${signer}" "$1" "/home/user/$4" "/home/user/$5")" ||
die "could not create the signer container" die "could not create the signer container"
docker cp "$2" "${container}:/home/user/$4" || die "could not copy $2 into the signer" 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 PKCS11_SLOT_INDEX="${INPUT_SLOT_INDEX:-0}"
export KEY_ID="${INPUT_KEY_ID:-03}" export KEY_ID="${INPUT_KEY_ID:-03}"
export KEY_ALIAS="${INPUT_KEY_ALIAS:-}" export KEY_ALIAS="${INPUT_KEY_ALIAS:-}"
export APK_CERT_SHA256="${INPUT_APK_CERT_SHA256:-}"
if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then
uhub_on uhub_on

View file

@ -4,9 +4,10 @@
# hsm-sign detached IN OUT a detached signature of IN (RSA PKCS#1 v1.5 over # hsm-sign detached IN OUT a detached signature of IN (RSA PKCS#1 v1.5 over
# SHA-256), written to OUT # SHA-256), written to OUT
# hsm-sign apk IN.apk OUT.apk sign an APK (signature schemes v2 and v3), then verify # 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 # hsm-sign make-cert SUBJECT setup for apk: a self-signed certificate for the key,
# the key, written to the token beside it, and printed # signed on the token, written to it beside the key,
# as PEM # 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 # hsm-sign list what Java sees on the token, for diagnosing an alias
# #
# Environment: # Environment:
@ -16,12 +17,15 @@
# KEY_ID the key's CKA_ID in hex; default 03 # KEY_ID the key's CKA_ID in hex; default 03
# KEY_LABEL the label make-cert gives the certificate; default apk-cert # 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 # 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 # 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, 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 # certificate with the same CKA_ID. Whatever certificate is there is used, whether
# until make-cert writes one. Android pins an app to its signing certificate for good, # make-cert wrote it or not. Android pins an app to its signing certificate for good,
# so make-cert runs once and its certificate is kept. # so it is chosen once and kept (and pinned with APK_CERT_SHA256).
set -eu set -eu
die() { die() {
@ -33,11 +37,33 @@ die() {
module="${PKCS11_MODULE:-$(find /usr/lib -name opensc-pkcs11.so | head -n 1)}" module="${PKCS11_MODULE:-$(find /usr/lib -name opensc-pkcs11.so | head -n 1)}"
[ -f "$module" ] || die "no PKCS#11 module at '$module'" [ -f "$module" ] || die "no PKCS#11 module at '$module'"
slot_index="${PKCS11_SLOT_INDEX:-0}" 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)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
chmod 700 "$work" 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. # SunPKCS11's configuration: which library, which slot.
cat > "$work/pkcs11.cfg" <<EOF cat > "$work/pkcs11.cfg" <<EOF
name = HSM name = HSM
@ -84,12 +110,42 @@ case "${1:-}" in
$alias_args \ $alias_args \
--in "$1" --out "$2" --in "$1" --out "$2"
# Proof, in the log, of what was signed and by whom. # Proof, in the log, of what was signed and by whom.
apksigner verify --verbose --print-certs "$2" verified="$(apksigner verify --verbose --print-certs "$2")"
echo "$verified"
signers="$(echo "$verified" | sed -n 's/^Number of signers: //p')"
[ "$signers" = 1 ] || die "expected 1 signer, apksigner reports '${signers}'"
got="$(echo "$verified" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
echo "hsm-sign: signed with the certificate whose sha256 (apk_cert_sha256) is ${got}"
# The certificate is the app's identity: with APK_CERT_SHA256 set, an APK signed
# with any other certificate (one replaced on the token, a second key) is an error,
# not an update every installed copy refuses.
if [ -n "${APK_CERT_SHA256:-}" ]; then
want="$(printf '%s' "${APK_CERT_SHA256}" | tr -d ': ' | tr 'A-F' 'a-f')"
if [ "$got" != "$want" ]; then
rm -f "$2"
die "the APK was signed with certificate ${got}, not the expected ${want} (APK_CERT_SHA256)"
fi
echo "hsm-sign: that is the expected certificate"
fi
;; ;;
make-cert) make-cert)
[ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'" [ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'"
subject="$2" subject="$2"
# The certificate is the app's identity on every device that installs an APK signed
# with it, so an existing one is not replaced by accident: it is shown, and kept,
# unless REPLACE_CERT=1.
if read_cert "$work/old.der"; then
echo "hsm-sign: key ${key_id} already has a certificate:" >&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 # 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. # the HSM's key. The PIN is in a file only this process can read, not an argument.
cat > "$work/openssl.cnf" <<EOF cat > "$work/openssl.cnf" <<EOF
@ -109,13 +165,30 @@ distinguished_name = dn
EOF EOF
uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')" uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')"
# Thirty years: the certificate's dates are not checked for APK signatures, and # Thirty years: the certificate's dates are not checked for APK signatures, and
# replacing it would mean a new app identity on every device. # replacing it would mean a new app identity on every device. Also long enough for
# Google Play, which wants validity past 2033-10-22.
OPENSSL_CONF="$work/openssl.cnf" openssl req -new -x509 -sha256 -days 10950 \ OPENSSL_CONF="$work/openssl.cnf" openssl req -new -x509 -sha256 -days 10950 \
-subj "$subject" -engine pkcs11 -keyform engine \ -subj "$subject" -engine pkcs11 -keyform engine \
-key "pkcs11:id=${uri_id};type=private" -out "$work/cert.pem" -key "pkcs11:id=${uri_id};type=private" -out "$work/cert.pem"
openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der" openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der"
if [ "$replacing" = 1 ]; then
# Removed first, so tokens that keep several objects with one id do not end up
# with two certificates. Cards with one certificate per key may refuse; the
# write below then replaces it, and the check after it says whether that worked.
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
--delete-object --type cert --id "$key_id" >&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 \ 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" cat "$work/cert.pem"
;; ;;

View file

@ -65,19 +65,48 @@ fi
openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact
[ -f in.apk ] || exit 0 [ -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" echo "== make-cert"
hsm-sign make-cert "/CN=hsm-sign test signer" > /out/cert.pem hsm-sign make-cert "/CN=hsm-sign test signer" > /out/cert.pem
openssl x509 -in /out/cert.pem -noout -subject -fingerprint -sha256 openssl x509 -in /out/cert.pem -noout -subject -fingerprint -sha256
echo "== apk" first="$(certsha /out/cert.pem)"
hsm-sign apk in.apk /out/signed.apk 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" echo "== independent verify"
apksigner verify --verbose /out/signed.apk apksigner verify --verbose /out/signed.apk
# The APK is signed by the key, not merely by a certificate naming it: the public key # The APK is signed by the key, not merely by a certificate naming it: the public key
# apksigner reports is the token'"'"'s. # apksigner reports is the token'"'"'s, and the certificate is the current one.
got="$(apksigner verify --verbose --print-certs /out/signed.apk | sed -n "s/^Signer #1 public key SHA-256 digest: //p")" 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 "token public key sha256: $want"
echo "APK signer key sha256: $got" echo "APK signer key sha256: $got"
echo "APK certificate sha256: $gotcert"
[ "$want" = "$got" ] [ "$want" = "$got" ]
[ "$gotcert" = "$second" ]
' '
echo "test.sh: detached signature verified" echo "test.sh: detached signature verified"
if [ -n "$apk_in" ]; then if [ -n "$apk_in" ]; then