action-hsm-sign/signer/hsm-sign.sh

203 lines
9.2 KiB
Bash
Executable file

#!/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 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:
# 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
# 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. 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() {
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}"
# 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
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.
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
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. 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}" >&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"
;;
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