Compare commits

...

3 commits
v3 ... master

5 changed files with 277 additions and 42 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,29 +65,60 @@ 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 with the user PIN, so the key never leaves it. Writing it
to an OpenPGP card (such as the Nitrokey Pro) takes the card's Admin PIN as
well: give it as `ADMIN_PIN`, and `make-cert` writes the certificate with
OpenSC's `pkcs15-init`, as Nitrokey documents for those cards. Without
`ADMIN_PIN`, the user PIN writes it over PKCS#11 (SoftHSM and similar tokens).
```sh
docker build -t hsm-signer signer
read -rs PKCS11_PIN && export PKCS11_PIN
read -rs ADMIN_PIN && export ADMIN_PIN
# Each PIN tried once: a wrong one costs a try, and a card locks a PIN after three
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 -e ADMIN_PIN hsm-signer check-pin
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
-e PKCS11_PIN -e ADMIN_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.
`check-pin` and `make-cert` refuse to try a PIN that is on its last try. A
correct PIN resets the count, so a normal signing run (with the PIN CI uses)
gets the tries back.
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` |
@ -93,6 +126,7 @@ Inputs
| `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) |
@ -122,9 +156,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,12 @@
# 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 check-pin check PKCS11_PIN (and ADMIN_PIN, if set) with one login
# each, before make-cert needs them
# hsm-sign list what Java sees on the token, for diagnosing an alias
#
# Environment:
@ -16,12 +19,17 @@
# 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
# ADMIN_PIN make-cert: the PIN that may write objects (pkcs15-init, auth id 3);
# required on OpenPGP cards (their Admin PIN). Unset: the user PIN
#
# 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 +41,53 @@ 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)"
}
# The label of the token in slot PKCS11_SLOT_INDEX, as pkcs11-tool numbers slots for
# --slot-index.
token_label() {
pkcs11-tool --module "$module" --list-slots 2>/dev/null | awk -v n="$slot_index" '
/^Slot [0-9]+ / { s = $2 }
s == n && /^[[:space:]]*token label[[:space:]]*:/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }'
}
# The flags of that token (PIN tries among them), as pkcs11-tool prints them.
token_flags() {
pkcs11-tool --module "$module" --list-slots 2>/dev/null | awk -v n="$slot_index" '
/^Slot [0-9]+ / { s = $2 }
s == n && /^[[:space:]]*token flags[[:space:]]*:/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }'
}
# $1 percent-encoded, every byte, for a pkcs11: URI (RFC 7512).
uri_encode() {
printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' | tr 'a-f' 'A-F' | sed 's/../%&/g'
}
# SunPKCS11's configuration: which library, which slot.
cat > "$work/pkcs11.cfg" <<EOF
name = HSM
@ -84,14 +134,56 @@ 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"
# make-cert logs in up to three times, and a wrong PIN costs a try each time: never
# start on a token with a PIN on its last try. (A correct PIN resets the count, so a
# normal signing run with the known-good PIN is the way back.)
flags="$(token_flags)"
case "$flags" in
*"final user PIN try"* | *"user PIN locked"* | *"final SO PIN try"* | *"SO PIN locked"*)
die "the token reports '${flags}': not risking a login. Sign once with the correct PIN to reset the count, then try again"
;;
esac
# 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.
# the HSM's key. The PIN is in a file only this process can read, not an argument,
# with every character but letters and digits backslash-escaped: in OpenSSL's config
# syntax # starts a comment and $ a variable, which would change the PIN sent.
pin_conf="$(printf '%s' "$PKCS11_PIN" | sed 's/[^A-Za-z0-9]/\\&/g')"
cat > "$work/openssl.cnf" <<EOF
openssl_conf = conf
[conf]
@ -101,30 +193,100 @@ pkcs11 = p11
[p11]
engine_id = pkcs11
MODULE_PATH = $module
PIN = $PKCS11_PIN
PIN = $pin_conf
init = 0
[req]
distinguished_name = dn
[dn]
EOF
uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')"
# The key's URI names its token: an OpenPGP card shows up as two tokens (User PIN
# and User PIN (sig)) with the same keys, and given only an id, OpenSSL's engine
# matches both and logs in to neither.
label="$(token_label)"
[ -n "$label" ] || die "no token in slot ${slot_index} (PKCS11_SLOT_INDEX)"
uri="pkcs11:token=$(uri_encode "$label");id=${uri_id};type=private"
echo "hsm-sign: signing the certificate with ${uri}" >&2
# 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"
-key "$uri" -out "$work/cert.pem"
openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der"
if [ -n "${ADMIN_PIN:-}" ]; then
# OpenPGP cards (the Nitrokey Pro): writing a certificate needs the Admin PIN, and
# goes through OpenSC's pkcs15-init with it (auth id 3), as Nitrokey documents for
# the Pro, rather than PKCS#11. OpenSC will not store a certificate under an id
# that already has one, so a replacement deletes it in the same run, as
# Nitrokey's own instructions do: the store then selects the key's certificate
# DO and writes over it. The read-back below checks the result either way.
if [ "$replacing" = 1 ]; then
set -- --delete-objects cert
else
set --
fi
pkcs15-init "$@" --store-certificate "$work/cert.pem" --format pem --id "$key_id" \
--auth-id 3 --verify-pin --pin env:ADMIN_PIN >&2 ||
die "pkcs15-init could not store the certificate; the read-back of what the card has now: $(read_cert "$work/now.der" && sha256sum "$work/now.der" | cut -d' ' -f1 || echo none). If none, put the old one back (the PEM above): pkcs15-init --store-certificate OLD.pem --id ${key_id} --auth-id 3 --verify-pin --pin env:ADMIN_PIN"
else
if [ "$replacing" = 1 ]; then
# Removed first, so tokens that keep several objects with one id do not end up
# with two certificates.
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}"
--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}" >&2
fi
# 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"
;;
check-pin)
# One login with PKCS11_PIN and, if ADMIN_PIN is set, one admin PIN check: says
# whether each is right before make-cert needs them. A wrong PIN costs a try, as
# it would anywhere, so a PIN on its last try is not checked.
flags="$(token_flags)"
echo "token flags: ${flags}"
case "$flags" in
*"final user PIN try"* | *"user PIN locked"*)
die "user PIN is on its last try or locked: not checking it" ;;
esac
if pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
--list-objects --type privkey >/dev/null 2>&1; then
echo "user PIN (PKCS11_PIN): correct"
else
die "user PIN (PKCS11_PIN): WRONG (one try used)"
fi
if [ -n "${ADMIN_PIN:-}" ]; then
case "$flags" in
*"final SO PIN try"* | *"SO PIN locked"*)
die "admin PIN is on its last try or locked: not checking it" ;;
esac
# The OpenPGP card's own VERIFY for PW3, the Admin PIN; OpenPGP cards only.
if openpgp-tool --verify CHV3 --pin env:ADMIN_PIN >/dev/null 2>&1; then
echo "admin PIN (ADMIN_PIN): correct"
else
die "admin PIN (ADMIN_PIN): WRONG (one try used)"
fi
fi
;;
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"
die "usage: hsm-sign detached IN OUT | apk IN OUT | make-cert SUBJECT | check-pin | list"
;;
esac

View file

@ -51,6 +51,9 @@ fi
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 second token, as the OpenPGP card shows two (User PIN and User PIN (sig)): the
# slot the key is on has to be named, not left to whichever token matches its id.
softhsm2-util --init-token --free --label "hsm-sign-test (sig)" --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
@ -65,19 +68,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