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 }}
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: '<sha256 printed by make-cert>'
# 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

View file

@ -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

View file

@ -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

View file

@ -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" <<EOF
name = HSM
@ -84,12 +110,42 @@ case "${1:-}" in
$alias_args \
--in "$1" --out "$2"
# 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)
[ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'"
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
# the HSM's key. The PIN is in a file only this process can read, not an argument.
cat > "$work/openssl.cnf" <<EOF
@ -109,13 +165,30 @@ distinguished_name = 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.
# 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 \
-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"
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 \
--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"
;;

View file

@ -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