292 lines
14 KiB
Bash
Executable file
292 lines
14 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 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:
|
|
# 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
|
|
# 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. 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)"
|
|
}
|
|
|
|
# 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
|
|
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"
|
|
# 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,
|
|
# 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]
|
|
engines = engines
|
|
[engines]
|
|
pkcs11 = p11
|
|
[p11]
|
|
engine_id = pkcs11
|
|
MODULE_PATH = $module
|
|
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. 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 "$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 \
|
|
--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 | check-pin | list"
|
|
;;
|
|
esac
|